diff --git a/.bazelrc b/.bazelrc index 76f81ade40..911e0802b5 100644 --- a/.bazelrc +++ b/.bazelrc @@ -79,6 +79,10 @@ common:ci --disk_cache= # Shared config for the main Bazel CI workflow. common:ci-bazel --config=ci common:ci-bazel --build_metadata=TAG_workflow=bazel +# Bazel CI cross-compiles in several legs, and the V8-backed code-mode tests +# are not stable in that setup yet. Keep running the rest of the Rust +# integration suites through the workspace-root launcher. +common:ci-bazel --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=suite::code_mode:: # Shared config for Bazel-backed Rust linting. build:clippy --aspects=@rules_rust//rust:defs.bzl%rust_clippy_aspect @@ -153,6 +157,25 @@ common:ci-macos --config=remote common:ci-macos --strategy=remote common:ci-macos --strategy=TestRunner=darwin-sandbox,local +# On Windows, use Linux remote execution for build actions but keep test actions +# on the Windows runner so Bazel's normal test sharding and flaky-test retries +# still run against Windows binaries. +common:ci-windows-cross --config=ci-windows +common:ci-windows-cross --build_metadata=TAG_windows_cross_compile=true +common:ci-windows-cross --config=remote +common:ci-windows-cross --host_platform=//:rbe +common:ci-windows-cross --strategy=remote +common:ci-windows-cross --strategy=TestRunner=local +common:ci-windows-cross --local_test_jobs=4 +common:ci-windows-cross --test_env=RUST_TEST_THREADS=1 +# Native Windows CI still covers the PowerShell tests. The cross-built gnullvm +# binaries currently hang in PowerShell AST parser tests when those binaries are +# run on the Windows runner. +common:ci-windows-cross --test_env=CODEX_BAZEL_TEST_SKIP_FILTERS=suite::code_mode::,powershell +common:ci-windows-cross --platforms=//:windows_x86_64_gnullvm +common:ci-windows-cross --extra_execution_platforms=//:rbe,//:windows_x86_64_msvc +common:ci-windows-cross --extra_toolchains=//:windows_gnullvm_tests_on_msvc_host_toolchain + # Linux-only V8 CI config. common:ci-v8 --config=ci common:ci-v8 --build_metadata=TAG_workflow=v8 @@ -160,5 +183,15 @@ common:ci-v8 --build_metadata=TAG_os=linux common:ci-v8 --config=remote common:ci-v8 --strategy=remote +# Source-built Bazel V8 artifacts use the in-process sandbox by default. This +# does not affect Cargo's default prebuilt rusty_v8 path. +common --@v8//:v8_enable_pointer_compression=True +common --@v8//:v8_enable_sandbox=True + +# Keep currently published rusty_v8 release artifacts non-sandboxed until the +# artifact migration ships matching Rust feature selection for Cargo consumers. +common:v8-release-compat --@v8//:v8_enable_pointer_compression=False +common:v8-release-compat --@v8//:v8_enable_sandbox=False + # Optional per-user local overrides. try-import %workspace%/user.bazelrc diff --git a/.codex/skills/codex-issue-digest/SKILL.md b/.codex/skills/codex-issue-digest/SKILL.md index e502e892fb..bae4bf9b67 100644 --- a/.codex/skills/codex-issue-digest/SKILL.md +++ b/.codex/skills/codex-issue-digest/SKILL.md @@ -53,7 +53,7 @@ Use `--window "past week"` or `--window-hours 168` when the user asks for a non- ## Summary No major issues reported by users. -Source: collector v4, git `abc123def456`, window `2026-04-27T00:00:00Z` to `2026-04-28T00:00:00Z`. +Source: collector v5, git `abc123def456`, window `2026-04-27T00:00:00Z` to `2026-04-28T00:00:00Z`. Want details? I can expand this into the issue table. ``` @@ -65,7 +65,7 @@ Two issues are being surfaced by users: 🔥🔥 Terminal launch hangs on startup [1](https://github.com/openai/codex/issues/123) 🔥 Resume switches model providers unexpectedly [2](https://github.com/openai/codex/issues/456) -Source: collector v4, git `abc123def456`, window `2026-04-27T00:00:00Z` to `2026-04-28T00:00:00Z`. +Source: collector v5, git `abc123def456`, window `2026-04-27T00:00:00Z` to `2026-04-28T00:00:00Z`. Want details? I can expand this into the issue table. ``` 5. In `## Details`, when details are requested, include a compact table only when useful: @@ -76,7 +76,7 @@ Want details? I can expand this into the issue table. - A clear quiet/no-concern sentence when there is no meaningful signal. 6. Use the JSON `attention_marker` exactly. It is empty for normal rows, `🔥` for elevated rows, and `🔥🔥` for very high-attention rows. The actual cutoffs are in `attention_thresholds`. 7. Use inline numbered references where a row or bullet points to issues, for example `Compaction bugs [1](https://github.com/openai/codex/issues/123), [2](https://github.com/openai/codex/issues/456)`. Do not add a separate footnotes section. -8. Label `interactions` as `Interactions`; it counts posts/comments/reactions during the requested window, not unique people. +8. Label `interactions` as `Interactions`; it counts unique human GitHub users who created a new issue, added a new comment, or reacted during the requested window. Multiple posts/reactions from the same user on the same issue count once. 9. Mention the collector `script_version`, repo checkout `git_head`, and time window in one compact source line. In default mode, put this before the details prompt so the final line still asks whether the user wants details. In details-upfront mode, it can be the footer. ## Reaction Handling @@ -89,7 +89,7 @@ GitHub issue search is still seeded by issue `updated_at`, so a purely reaction- ## Attention Markers -The collector scales attention markers by the requested time window. The baseline is 5 human user interactions for `🔥` and 10 for `🔥🔥` over 24 hours; longer or shorter windows scale those cutoffs linearly and round up. For example, a one-week report uses 35 and 70 interactions. Human user interactions are human-authored new issue posts, human-authored new comments, and human reactions created during the window, including upvotes. Bot posts and bot reactions are excluded. In prose, explain this as high user interaction rather than naming the emoji. +The collector scales attention markers by the requested time window. The baseline is 5 unique human users for `🔥` and 10 unique human users for `🔥🔥` over 24 hours; longer or shorter windows scale those cutoffs linearly and round up. For example, a one-week report uses 35 and 70 interactions. Unique human users are users who authored a new issue, authored a new comment, or reacted during the window, including upvotes. Multiple actions from the same user on the same issue count once. Bot posts and bot reactions are excluded. In prose, explain this as high user interaction rather than naming the emoji. ## Freshness diff --git a/.codex/skills/codex-issue-digest/scripts/collect_issue_digest.py b/.codex/skills/codex-issue-digest/scripts/collect_issue_digest.py index a4f3982db2..80b54d6106 100755 --- a/.codex/skills/codex-issue-digest/scripts/collect_issue_digest.py +++ b/.codex/skills/codex-issue-digest/scripts/collect_issue_digest.py @@ -11,7 +11,7 @@ from datetime import datetime, timedelta, timezone from pathlib import Path from urllib.parse import quote -SCRIPT_VERSION = 4 +SCRIPT_VERSION = 5 QUALIFYING_KIND_LABELS = ("bug", "enhancement") REACTION_KEYS = ("+1", "-1", "laugh", "hooray", "confused", "heart", "rocket", "eyes") BASE_ATTENTION_WINDOW_HOURS = 24.0 @@ -393,9 +393,15 @@ def is_bot_login(login): return bool(login) and login.lower().endswith("[bot]") -def is_human_user(user_obj): +def human_login_key(user_obj): login = extract_login(user_obj) - return bool(login) and not is_bot_login(login) + if not login or is_bot_login(login): + return "" + return login.casefold() + + +def is_human_user(user_obj): + return bool(human_login_key(user_obj)) def label_names(issue): @@ -467,22 +473,26 @@ def reaction_summary(item): def reaction_event_summary(reactions, since, until): counts = {} total = 0 + users = set() for reaction in reactions or []: if not isinstance(reaction, dict): continue if not is_in_window(str(reaction.get("created_at") or ""), since, until): continue - if not is_human_user(reaction.get("user")): + user_key = human_login_key(reaction.get("user")) + if not user_key: continue content = str(reaction.get("content") or "") if not content: continue counts[content] = counts.get(content, 0) + 1 total += 1 + users.add(user_key) return { "total": total, "counts": counts, "upvotes": counts.get("+1", 0), + "users": sorted(users, key=str.casefold), } @@ -618,13 +628,21 @@ def summarize_issue( new_comment_reaction_total = sum( comment["reaction_total"] for comment in new_comments ) - new_issue_user_interaction = new_issue and is_human_user(issue.get("user")) + new_issue_user_key = human_login_key(issue.get("user")) if new_issue else "" + new_issue_user_interaction = bool(new_issue_user_key) new_comment_user_interactions = sum( 1 for comment in new_comments if comment["human_user_interaction"] ) - user_interactions = ( - int(new_issue_user_interaction) + new_comment_user_interactions + new_reactions + interaction_user_keys = set(issue_reaction_events_summary["users"]) + interaction_user_keys.update(comment_reaction_events_summary["users"]) + if new_issue_user_key: + interaction_user_keys.add(new_issue_user_key) + interaction_user_keys.update( + comment["author"].casefold() + for comment in new_comments + if comment["human_user_interaction"] ) + user_interactions = len(interaction_user_keys) attention_level = attention_level_for(user_interactions, attention_thresholds) attention_marker = attention_marker_for(user_interactions, attention_thresholds) updated_without_visible_new_post = ( @@ -957,6 +975,7 @@ def collect_digest(args): "New issue comments are filtered by comment creation time within the window from the fetched comment set.", "Reaction events are counted by GitHub reaction created_at timestamps for hydrated issues and fetched comments.", "Current reaction totals are standing engagement signals; new_reactions and new_upvotes are windowed activity.", + "user_interactions counts unique human users per issue across new issues, new comments, and new reactions; repeated actions by the same user count once.", "The collector does not assign semantic clusters; use summary_inputs as model-ready evidence for report-time clustering.", "Pure reaction-only issues may be missed if GitHub issue search does not surface them via updated_at.", "Issues updated during the window without a new issue body or new comment are retained because label/status edits can still be useful owner signals.", diff --git a/.codex/skills/codex-issue-digest/scripts/test_collect_issue_digest.py b/.codex/skills/codex-issue-digest/scripts/test_collect_issue_digest.py index 8619f867ac..f1ba54e7f0 100644 --- a/.codex/skills/codex-issue-digest/scripts/test_collect_issue_digest.py +++ b/.codex/skills/codex-issue-digest/scripts/test_collect_issue_digest.py @@ -494,6 +494,70 @@ def test_reactions_count_toward_attention_markers(): assert summary["new_comments"][0]["new_upvotes"] == 0 +def test_user_interactions_are_deduped_by_human_login(): + since = collect_issue_digest.parse_timestamp("2026-04-25T00:00:00Z", "--since") + until = collect_issue_digest.parse_timestamp("2026-04-26T00:00:00Z", "--until") + + def comment(comment_id, login): + return { + "id": comment_id, + "created_at": f"2026-04-25T0{comment_id + 1}:00:00Z", + "updated_at": f"2026-04-25T0{comment_id + 1}:00:00Z", + "user": {"login": login}, + "body": "same issue", + } + + def reaction(content, login, created_at="2026-04-25T10:00:00Z"): + return { + "content": content, + "created_at": created_at, + "user": {"login": login}, + } + + issue = { + "number": 790, + "title": "Repeated pings should not boost attention", + "html_url": "https://github.com/openai/codex/issues/790", + "state": "open", + "created_at": "2026-04-25T01:00:00Z", + "updated_at": "2026-04-25T12:00:00Z", + "user": {"login": "Alice"}, + "labels": [{"name": "bug"}, {"name": "tui"}], + } + comments = [comment(1, "alice"), comment(2, "ALICE"), comment(3, "bob")] + comments.append(comment(4, "github-actions[bot]")) + issue_reactions = [ + reaction("+1", "alice"), + reaction("rocket", "Alice"), + reaction("+1", "bob"), + reaction("+1", "github-actions[bot]"), + reaction("+1", "carol", created_at="2026-04-24T23:00:00Z"), + ] + comment_reactions_by_id = { + 1: [reaction("heart", "alice")], + 2: [reaction("+1", "bob")], + 3: [reaction("eyes", "carol")], + } + + summary = collect_issue_digest.summarize_issue( + issue, + comments, + ["tui"], + since, + until, + body_chars=100, + comment_chars=100, + issue_reaction_events=issue_reactions, + comment_reactions_by_id=comment_reactions_by_id, + ) + + assert summary["activity"]["new_human_comments"] == 3 + assert summary["new_reactions"] == 6 + assert summary["user_interactions"] == 3 + assert summary["attention"] is False + assert summary["attention_marker"] == "" + + def test_digest_rows_are_table_ready_with_concise_descriptions(): rows = collect_issue_digest.digest_rows( [ diff --git a/.github/ISSUE_TEMPLATE/3-cli.yml b/.github/ISSUE_TEMPLATE/3-cli.yml index 4aff813e5f..37229c7f2b 100644 --- a/.github/ISSUE_TEMPLATE/3-cli.yml +++ b/.github/ISSUE_TEMPLATE/3-cli.yml @@ -2,7 +2,6 @@ name: 💻 CLI Bug description: Report an issue in the Codex CLI labels: - bug - - needs triage body: - type: markdown attributes: @@ -41,9 +40,9 @@ body: id: terminal attributes: label: What terminal emulator and version are you using (if applicable)? - description: Also note any multiplexer in use (screen / tmux / zellij) description: | - E.g, VSCode, Terminal.app, iTerm2, Ghostty, Windows Terminal (WSL / PowerShell) + Also note any multiplexer in use (screen / tmux / zellij). + E.g., VS Code, Terminal.app, iTerm2, Ghostty, Windows Terminal (WSL / PowerShell) - type: textarea id: actual attributes: diff --git a/.github/ISSUE_TEMPLATE/5-feature-request.yml b/.github/ISSUE_TEMPLATE/5-feature-request.yml index 55ff9fbbcd..745c347965 100644 --- a/.github/ISSUE_TEMPLATE/5-feature-request.yml +++ b/.github/ISSUE_TEMPLATE/5-feature-request.yml @@ -10,7 +10,7 @@ body: Before you submit a feature: 1. Search existing issues for similar features. If you find one, 👍 it rather than opening a new one. - 2. The Codex team will try to balance the varying needs of the community when prioritizing or rejecting new features. Not all features will be accepted. See [Contributing](https://github.com/openai/codex#contributing) for more details. + 2. The Codex team will try to balance the varying needs of the community when prioritizing or rejecting new features. Not all features will be accepted. See [Contributing](https://github.com/openai/codex/blob/main/docs/contributing.md) for more details. - type: input id: variant diff --git a/.github/ISSUE_TEMPLATE/6-docs-issue.yml b/.github/ISSUE_TEMPLATE/6-docs-issue.yml index 456602e6ac..1957b6035a 100644 --- a/.github/ISSUE_TEMPLATE/6-docs-issue.yml +++ b/.github/ISSUE_TEMPLATE/6-docs-issue.yml @@ -1,6 +1,6 @@ name: 📗 Documentation Issue description: Tell us if there is missing or incorrect documentation -labels: [docs] +labels: [documentation] body: - type: markdown attributes: @@ -24,4 +24,4 @@ body: - type: textarea attributes: label: Where did you find it? - description: If possible, please provide the URL(s) where you found this issue. \ No newline at end of file + description: If possible, please provide the URL(s) where you found this issue. diff --git a/.github/actions/prepare-bazel-ci/action.yml b/.github/actions/prepare-bazel-ci/action.yml index 48c6ba74b4..b41d80e0bc 100644 --- a/.github/actions/prepare-bazel-ci/action.yml +++ b/.github/actions/prepare-bazel-ci/action.yml @@ -50,7 +50,7 @@ runs: - name: Restore bazel repository cache id: cache_bazel_repository_restore continue-on-error: true - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ${{ steps.setup_bazel.outputs.repository-cache-path }} key: ${{ steps.cache_bazel_repository_key.outputs.repository-cache-key }} diff --git a/.github/actions/windows-code-sign/action.yml b/.github/actions/windows-code-sign/action.yml index 6289fa917d..634d647e06 100644 --- a/.github/actions/windows-code-sign/action.yml +++ b/.github/actions/windows-code-sign/action.yml @@ -30,7 +30,7 @@ runs: using: composite steps: - name: Azure login for Trusted Signing (OIDC) - uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2 + uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0 with: client-id: ${{ inputs.client-id }} tenant-id: ${{ inputs.tenant-id }} @@ -54,7 +54,7 @@ runs: } >> "$GITHUB_OUTPUT" - name: Sign Windows binaries with Azure Trusted Signing - uses: azure/trusted-signing-action@1d365fec12862c4aa68fcac418143d73f0cea293 # v0 + uses: azure/trusted-signing-action@1d365fec12862c4aa68fcac418143d73f0cea293 # v0.5.11 with: endpoint: ${{ inputs.endpoint }} trusted-signing-account-name: ${{ inputs.account-name }} diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index bb67fe6896..9eeb8f1028 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -6,25 +6,37 @@ updates: directory: .github/actions/codex schedule: interval: weekly + cooldown: + default-days: 7 - package-ecosystem: cargo directories: - codex-rs - codex-rs/* schedule: interval: weekly + cooldown: + default-days: 7 - package-ecosystem: devcontainers directory: / schedule: interval: weekly + cooldown: + default-days: 7 - package-ecosystem: docker directory: codex-cli schedule: interval: weekly + cooldown: + default-days: 7 - package-ecosystem: github-actions directory: / schedule: interval: weekly + cooldown: + default-days: 7 - package-ecosystem: rust-toolchain directory: codex-rs schedule: interval: weekly + cooldown: + default-days: 7 diff --git a/.github/dotslash-config.json b/.github/dotslash-config.json index 5caef01e85..a0297c269a 100644 --- a/.github/dotslash-config.json +++ b/.github/dotslash-config.json @@ -11,11 +11,11 @@ "path": "codex" }, "linux-x86_64": { - "regex": "^codex-x86_64-unknown-linux-musl\\.zst$", + "regex": "^codex-x86_64-unknown-linux-musl-bundle\\.tar\\.zst$", "path": "codex" }, "linux-aarch64": { - "regex": "^codex-aarch64-unknown-linux-musl\\.zst$", + "regex": "^codex-aarch64-unknown-linux-musl-bundle\\.tar\\.zst$", "path": "codex" }, "windows-x86_64": { @@ -84,6 +84,18 @@ } } }, + "bwrap": { + "platforms": { + "linux-x86_64": { + "regex": "^bwrap-x86_64-unknown-linux-musl\\.zst$", + "path": "bwrap" + }, + "linux-aarch64": { + "regex": "^bwrap-aarch64-unknown-linux-musl\\.zst$", + "path": "bwrap" + } + } + }, "codex-command-runner": { "platforms": { "windows-x86_64": { diff --git a/.github/scripts/compute-bazel-windows-path.ps1 b/.github/scripts/compute-bazel-windows-path.ps1 index 6b6bbe0462..81fd668c8b 100644 --- a/.github/scripts/compute-bazel-windows-path.ps1 +++ b/.github/scripts/compute-bazel-windows-path.ps1 @@ -5,9 +5,9 @@ tool entries, such as Maven, that can change independently of this repo and cause avoidable cache misses. This script derives a smaller, cache-stable PATH that keeps the Windows -toolchain entries Bazel-backed CI tasks need: MSVC and Windows SDK paths, Git, -PowerShell, Node, Python, DotSlash, and the standard Windows system -directories. +toolchain entries Bazel-backed CI tasks need: MSVC and Windows SDK paths, +MinGW runtime DLL paths for gnullvm-built tests, Git, PowerShell, Node, Python, +DotSlash, and the standard Windows system directories. `setup-bazel-ci` runs this after exporting the MSVC environment, and the script publishes the result via `GITHUB_ENV` as `CODEX_BAZEL_WINDOWS_PATH` so later steps can pass that explicit PATH to Bazel. @@ -49,6 +49,8 @@ foreach ($pathEntry in ($env:PATH -split ';')) { $pathEntry -like '*Microsoft Visual Studio*' -or $pathEntry -like '*Windows Kits*' -or $pathEntry -like '*Microsoft SDKs*' -or + $pathEntry -eq 'C:\mingw64\bin' -or + $pathEntry -like 'C:\msys64\*\bin' -or $pathEntry -like 'C:\Program Files\Git\*' -or $pathEntry -like 'C:\Program Files\PowerShell\*' -or $pathEntry -like 'C:\hostedtoolcache\windows\node\*' -or @@ -85,6 +87,12 @@ if ($pwshCommand) { Add-StablePathEntry (Split-Path $pwshCommand.Source -Parent) } +foreach ($mingwPath in @('C:\mingw64\bin', 'C:\msys64\mingw64\bin', 'C:\msys64\ucrt64\bin')) { + if (Test-Path $mingwPath) { + Add-StablePathEntry $mingwPath + } +} + if ($windowsAppsPath) { Add-StablePathEntry $windowsAppsPath } diff --git a/.github/scripts/run-bazel-ci.sh b/.github/scripts/run-bazel-ci.sh index b81e0a4d57..f98e4d8cb9 100755 --- a/.github/scripts/run-bazel-ci.sh +++ b/.github/scripts/run-bazel-ci.sh @@ -6,6 +6,7 @@ print_failed_bazel_test_logs=0 print_failed_bazel_action_summary=0 remote_download_toplevel=0 windows_msvc_host_platform=0 +windows_cross_compile=0 while [[ $# -gt 0 ]]; do case "$1" in @@ -25,6 +26,10 @@ while [[ $# -gt 0 ]]; do windows_msvc_host_platform=1 shift ;; + --windows-cross-compile) + windows_cross_compile=1 + shift + ;; --) shift break @@ -37,7 +42,7 @@ while [[ $# -gt 0 ]]; do done if [[ $# -eq 0 ]]; then - echo "Usage: $0 [--print-failed-test-logs] [--print-failed-action-summary] [--remote-download-toplevel] [--windows-msvc-host-platform] -- -- " >&2 + echo "Usage: $0 [--print-failed-test-logs] [--print-failed-action-summary] [--remote-download-toplevel] [--windows-msvc-host-platform] [--windows-cross-compile] -- -- " >&2 exit 1 fi @@ -61,7 +66,11 @@ case "${RUNNER_OS:-}" in ci_config=ci-macos ;; Windows) - ci_config=ci-windows + if [[ $windows_cross_compile -eq 1 ]]; then + ci_config=ci-windows-cross + else + ci_config=ci-windows + fi ;; esac @@ -105,8 +114,8 @@ print_bazel_test_log_tails() { while IFS= read -r target; do failed_targets+=("$target") done < <( - grep -E '^FAIL: //' "$console_log" \ - | sed -E 's#^FAIL: (//[^ ]+).*#\1#' \ + grep -E '^(FAIL: //|ERROR: .* Testing //)' "$console_log" \ + | sed -E 's#^FAIL: (//[^ ]+).*#\1#; s#^ERROR: .* Testing (//[^ ]+) failed:.*#\1#' \ | sort -u ) @@ -244,6 +253,12 @@ if [[ ${#bazel_args[@]} -eq 0 || ${#bazel_targets[@]} -eq 0 ]]; then exit 1 fi +if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -z "${BUILDBUDDY_API_KEY:-}" ]]; then + # Fork PRs do not receive the BuildBuddy secret needed for the remote + # cross-compile config. Preserve the previous local Windows build shape. + windows_msvc_host_platform=1 +fi + post_config_bazel_args=() if [[ "${RUNNER_OS:-}" == "Windows" && $windows_msvc_host_platform -eq 1 ]]; then has_host_platform_override=0 @@ -269,6 +284,25 @@ if [[ $remote_download_toplevel -eq 1 ]]; then post_config_bazel_args+=(--remote_download_toplevel) fi +if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -n "${BUILDBUDDY_API_KEY:-}" ]]; then + # `--enable_platform_specific_config` expands `common:windows` on Windows + # hosts after ordinary rc configs, which can override `ci-windows-cross`'s + # RBE host platform. Repeat the host platform on the command line so V8 and + # other genrules execute on Linux RBE workers instead of Git Bash locally. + # + # Bazel also derives the default genrule shell from the client host. Without + # an explicit shell executable, remote Linux actions can be asked to run + # `C:\Program Files\Git\usr\bin\bash.exe`. + post_config_bazel_args+=(--host_platform=//:rbe --shell_executable=/bin/bash) +fi + +if [[ "${RUNNER_OS:-}" == "Windows" && $windows_cross_compile -eq 1 && -z "${BUILDBUDDY_API_KEY:-}" ]]; then + # The Windows cross-compile config depends on remote execution. Fork PRs do + # not receive the BuildBuddy secret, so fall back to the existing local build + # shape and keep its lower concurrency cap. + post_config_bazel_args+=(--jobs=8) +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 @@ -287,37 +321,57 @@ if [[ -n "${CODEX_BAZEL_EXECUTION_LOG_COMPACT_DIR:-}" ]]; then fi if [[ "${RUNNER_OS:-}" == "Windows" ]]; then - windows_action_env_vars=( - INCLUDE - LIB - LIBPATH - UCRTVersion - UniversalCRTSdkDir - VCINSTALLDIR - VCToolsInstallDir - WindowsLibPath - WindowsSdkBinPath - WindowsSdkDir - WindowsSDKLibVersion - WindowsSDKVersion - ) + pass_windows_build_env=1 + if [[ $windows_cross_compile -eq 1 && -n "${BUILDBUDDY_API_KEY:-}" ]]; then + # Remote build actions execute on Linux RBE workers. Passing the Windows + # runner's build environment there makes Bazel genrules try to execute + # C:\Program Files\Git\usr\bin\bash.exe on Linux. + pass_windows_build_env=0 + fi - 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 + if [[ $pass_windows_build_env -eq 1 ]]; then + windows_action_env_vars=( + INCLUDE + LIB + LIBPATH + 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 if [[ -z "${CODEX_BAZEL_WINDOWS_PATH:-}" ]]; then echo "CODEX_BAZEL_WINDOWS_PATH must be set for Windows Bazel CI." >&2 exit 1 fi - post_config_bazel_args+=( - "--action_env=PATH=${CODEX_BAZEL_WINDOWS_PATH}" - "--host_action_env=PATH=${CODEX_BAZEL_WINDOWS_PATH}" - "--test_env=PATH=${CODEX_BAZEL_WINDOWS_PATH}" - ) + if [[ $pass_windows_build_env -eq 1 ]]; then + post_config_bazel_args+=( + "--action_env=PATH=${CODEX_BAZEL_WINDOWS_PATH}" + "--host_action_env=PATH=${CODEX_BAZEL_WINDOWS_PATH}" + ) + elif [[ $windows_cross_compile -eq 1 ]]; then + # Remote build actions run on Linux RBE workers. Give their shell snippets + # a Linux PATH while preserving CODEX_BAZEL_WINDOWS_PATH below for local + # Windows test execution. + post_config_bazel_args+=( + "--action_env=PATH=/usr/bin:/bin" + "--host_action_env=PATH=/usr/bin:/bin" + ) + fi + post_config_bazel_args+=("--test_env=PATH=${CODEX_BAZEL_WINDOWS_PATH}") fi bazel_console_log="$(mktemp)" diff --git a/.github/scripts/run-bazel-query-ci.sh b/.github/scripts/run-bazel-query-ci.sh index 1ed664e44b..dd03b67169 100755 --- a/.github/scripts/run-bazel-query-ci.sh +++ b/.github/scripts/run-bazel-query-ci.sh @@ -6,8 +6,13 @@ set -euo pipefail # invocation so target-discovery queries can reuse the same Bazel server. query_args=() +windows_cross_compile=0 while [[ $# -gt 0 ]]; do case "$1" in + --windows-cross-compile) + windows_cross_compile=1 + shift + ;; --) shift break @@ -20,7 +25,7 @@ while [[ $# -gt 0 ]]; do done if [[ $# -ne 1 ]]; then - echo "Usage: $0 [...] -- " >&2 + echo "Usage: $0 [--windows-cross-compile] [...] -- " >&2 exit 1 fi @@ -32,7 +37,11 @@ case "${RUNNER_OS:-}" in ci_config=ci-macos ;; Windows) - ci_config=ci-windows + if [[ $windows_cross_compile -eq 1 ]]; then + ci_config=ci-windows-cross + else + ci_config=ci-windows + fi ;; esac diff --git a/.github/scripts/rusty_v8_bazel.py b/.github/scripts/rusty_v8_bazel.py index ec73e0e5a7..5ad6d3c408 100644 --- a/.github/scripts/rusty_v8_bazel.py +++ b/.github/scripts/rusty_v8_bazel.py @@ -63,8 +63,10 @@ def bazel_output_files( platform: str, labels: list[str], compilation_mode: str = "fastbuild", + bazel_configs: list[str] | None = None, ) -> list[Path]: expression = "set(" + " ".join(labels) + ")" + bazel_configs = bazel_configs or [] result = subprocess.run( [ "bazel", @@ -72,6 +74,7 @@ def bazel_output_files( "-c", compilation_mode, f"--platforms=@llvm//platforms:{platform}", + *[f"--config={config}" for config in bazel_configs], "--output=files", expression, ], @@ -87,7 +90,9 @@ def bazel_build( platform: str, labels: list[str], compilation_mode: str = "fastbuild", + bazel_configs: list[str] | None = None, ) -> None: + bazel_configs = bazel_configs or [] subprocess.run( [ "bazel", @@ -95,6 +100,7 @@ def bazel_build( "-c", compilation_mode, f"--platforms=@llvm//platforms:{platform}", + *[f"--config={config}" for config in bazel_configs], *labels, ], cwd=ROOT, @@ -106,13 +112,14 @@ def ensure_bazel_output_files( platform: str, labels: list[str], compilation_mode: str = "fastbuild", + bazel_configs: list[str] | None = None, ) -> list[Path]: - outputs = bazel_output_files(platform, labels, compilation_mode) + outputs = bazel_output_files(platform, labels, compilation_mode, bazel_configs) if all(path.exists() for path in outputs): return outputs - bazel_build(platform, labels, compilation_mode) - outputs = bazel_output_files(platform, labels, compilation_mode) + bazel_build(platform, labels, compilation_mode, bazel_configs) + outputs = bazel_output_files(platform, labels, compilation_mode, bazel_configs) missing = [str(path) for path in outputs if not path.exists()] if missing: raise SystemExit(f"missing built outputs for {labels}: {missing}") @@ -187,8 +194,9 @@ def single_bazel_output_file( platform: str, label: str, compilation_mode: str = "fastbuild", + bazel_configs: list[str] | None = None, ) -> Path: - outputs = ensure_bazel_output_files(platform, [label], compilation_mode) + outputs = ensure_bazel_output_files(platform, [label], compilation_mode, bazel_configs) if len(outputs) != 1: raise SystemExit(f"expected exactly one output for {label}, found {outputs}") return outputs[0] @@ -198,11 +206,17 @@ def merged_musl_archive( platform: str, lib_path: Path, compilation_mode: str = "fastbuild", + bazel_configs: list[str] | None = None, ) -> Path: - llvm_ar = single_bazel_output_file(platform, LLVM_AR_LABEL, compilation_mode) - llvm_ranlib = single_bazel_output_file(platform, LLVM_RANLIB_LABEL, compilation_mode) + llvm_ar = single_bazel_output_file(platform, LLVM_AR_LABEL, compilation_mode, bazel_configs) + llvm_ranlib = single_bazel_output_file( + platform, + LLVM_RANLIB_LABEL, + compilation_mode, + bazel_configs, + ) runtime_archives = [ - single_bazel_output_file(platform, label, compilation_mode) + single_bazel_output_file(platform, label, compilation_mode, bazel_configs) for label in MUSL_RUNTIME_ARCHIVE_LABELS ] @@ -233,11 +247,13 @@ def stage_release_pair( target: str, output_dir: Path, compilation_mode: str = "fastbuild", + bazel_configs: list[str] | None = None, ) -> None: outputs = ensure_bazel_output_files( platform, [release_pair_label(target)], compilation_mode, + bazel_configs, ) try: @@ -254,7 +270,7 @@ def stage_release_pair( staged_library = output_dir / staged_archive_name(target, lib_path) staged_binding = output_dir / f"src_binding_release_{target}.rs" source_archive = ( - merged_musl_archive(platform, lib_path, compilation_mode) + merged_musl_archive(platform, lib_path, compilation_mode, bazel_configs) if is_musl_archive_target(target, lib_path) else lib_path ) @@ -293,6 +309,12 @@ def parse_args() -> argparse.Namespace: stage_release_pair_parser.add_argument("--platform", required=True) stage_release_pair_parser.add_argument("--target", required=True) stage_release_pair_parser.add_argument("--output-dir", required=True) + stage_release_pair_parser.add_argument( + "--bazel-config", + action="append", + default=[], + dest="bazel_configs", + ) stage_release_pair_parser.add_argument( "--compilation-mode", default="fastbuild", @@ -330,6 +352,7 @@ def main() -> int: target=args.target, output_dir=Path(args.output_dir), compilation_mode=args.compilation_mode, + bazel_configs=args.bazel_configs, ) return 0 if args.command == "resolved-v8-crate-version": diff --git a/.github/scripts/verify_cargo_workspace_manifests.py b/.github/scripts/verify_cargo_workspace_manifests.py index 4812e2428d..93b41ea59f 100644 --- a/.github/scripts/verify_cargo_workspace_manifests.py +++ b/.github/scripts/verify_cargo_workspace_manifests.py @@ -25,7 +25,10 @@ TOP_LEVEL_NAME_EXCEPTIONS = { UTILITY_NAME_EXCEPTIONS = { "path-utils": "codex-utils-path", } -MANIFEST_FEATURE_EXCEPTIONS = {} +MANIFEST_FEATURE_EXCEPTIONS = { + "codex-rs/code-mode/Cargo.toml": {"sandbox": ("v8/v8_enable_sandbox",)}, + "codex-rs/v8-poc/Cargo.toml": {"sandbox": ("v8/v8_enable_sandbox",)}, +} OPTIONAL_DEPENDENCY_EXCEPTIONS = set() INTERNAL_DEPENDENCY_FEATURE_EXCEPTIONS = {} diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index ef41330c46..cc3968d306 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -17,13 +17,10 @@ concurrency: cancel-in-progress: ${{ github.ref_name != 'main' }} jobs: test: - # Even though a no-cache-hit Windows build seems to exceed the 30-minute - # limit on occasion, the more common reason for exceeding the limit is a - # true test failure in a rust_test() marked "flaky" that gets run 3x. - # In that case, extra time generally does not give us more signal. - # - # Ultimately we need true distributed builds (e.g., - # https://www.buildbuddy.io/docs/rbe-setup/) to speed things up. + # PRs use a fast Windows cross-compiled test leg for pre-merge signal. + # Post-merge pushes to main also run the native Windows test job below for + # broader Windows signal without putting PR latency back on the critical + # path. Cargo CI owns V8/code-mode test coverage for now. timeout-minutes: 30 strategy: fail-fast: false @@ -47,16 +44,22 @@ jobs: # - os: ubuntu-24.04-arm # target: aarch64-unknown-linux-gnu - # Windows + # Windows fast path: build the windows-gnullvm binaries with Linux + # RBE, then run the resulting Windows tests on the Windows runner. + # Cargo CI preserves V8/code-mode coverage while Bazel CI keeps broad + # non-code-mode signal. - os: windows-latest target: x86_64-pc-windows-gnullvm runs-on: ${{ matrix.os }} # Configure a human readable name for each job - name: Local Bazel build on ${{ matrix.os }} for ${{ matrix.target }} + name: Bazel test on ${{ matrix.os }} for ${{ matrix.target }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Check rusty_v8 MODULE.bazel checksums if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu' @@ -88,9 +91,15 @@ jobs: # path. V8 consumers under `//codex-rs/...` still participate # transitively through `//...`. -//third_party/v8:all + # V8-backed code-mode tests are covered by Cargo CI. Bazel CI + # cross-compiles in several legs, and those tests are not stable in + # that setup yet. + -//codex-rs/code-mode:code-mode-unit-tests + -//codex-rs/v8-poc:v8-poc-unit-tests ) bazel_wrapper_args=( + --print-failed-action-summary --print-failed-test-logs ) bazel_test_args=( @@ -100,8 +109,10 @@ jobs: --build_metadata=COMMIT_SHA=${GITHUB_SHA} ) if [[ "${RUNNER_OS}" == "Windows" ]]; then - bazel_wrapper_args+=(--windows-msvc-host-platform) - bazel_test_args+=(--jobs=8) + bazel_wrapper_args+=( + --windows-cross-compile + --remote-download-toplevel + ) fi ./.github/scripts/run-bazel-ci.sh \ @@ -114,7 +125,7 @@ jobs: - name: Upload Bazel execution logs if: always() && !cancelled() continue-on-error: true - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: bazel-execution-logs-test-${{ matrix.target }} path: ${{ runner.temp }}/bazel-execution-logs @@ -125,7 +136,83 @@ jobs: - name: Save bazel repository cache if: always() && !cancelled() && steps.prepare_bazel.outputs.repository-cache-hit != 'true' continue-on-error: true - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: ${{ steps.prepare_bazel.outputs.repository-cache-path }} + key: ${{ steps.prepare_bazel.outputs.repository-cache-key }} + + test-windows-native-main: + # Native Windows Bazel tests are slower and frequently approach the + # 30-minute PR budget. Run this only for post-merge commits to main and give + # it a larger timeout. + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + timeout-minutes: 40 + runs-on: windows-latest + name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm (native main) + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Prepare Bazel CI + id: prepare_bazel + uses: ./.github/actions/prepare-bazel-ci + with: + target: x86_64-pc-windows-gnullvm + cache-scope: bazel-${{ github.job }} + install-test-prereqs: "true" + + - name: bazel test //... + env: + BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} + shell: bash + run: | + bazel_targets=( + //... + # Keep standalone V8 library targets out of the ordinary Bazel CI + # path. V8 consumers under `//codex-rs/...` still participate + # transitively through `//...`. + -//third_party/v8:all + # Keep this aligned with the main Bazel job. The native Windows + # job preserves broad post-merge coverage, but code-mode/V8 tests + # are covered by Cargo CI rather than Bazel for now. + -//codex-rs/code-mode:code-mode-unit-tests + -//codex-rs/v8-poc:v8-poc-unit-tests + ) + + bazel_test_args=( + test + --test_tag_filters=-argument-comment-lint + --test_verbose_timeout_warnings + --build_metadata=COMMIT_SHA=${GITHUB_SHA} + --build_metadata=TAG_windows_native_main=true + ) + + ./.github/scripts/run-bazel-ci.sh \ + --print-failed-action-summary \ + --print-failed-test-logs \ + -- \ + "${bazel_test_args[@]}" \ + -- \ + "${bazel_targets[@]}" + + - name: Upload Bazel execution logs + if: always() && !cancelled() + continue-on-error: true + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: bazel-execution-logs-test-windows-native-x86_64-pc-windows-gnullvm + path: ${{ runner.temp }}/bazel-execution-logs + if-no-files-found: ignore + + # Save the job-scoped Bazel repository cache after cache misses. Keep the + # upload non-fatal so cache service issues never fail the job itself. + - name: Save bazel repository cache + if: always() && !cancelled() && steps.prepare_bazel.outputs.repository-cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ${{ steps.prepare_bazel.outputs.repository-cache-path }} key: ${{ steps.prepare_bazel.outputs.repository-cache-key }} @@ -150,7 +237,10 @@ jobs: name: Bazel clippy on ${{ matrix.os }} for ${{ matrix.target }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Prepare Bazel CI id: prepare_bazel @@ -170,17 +260,24 @@ jobs: --build_metadata=TAG_job=clippy ) bazel_wrapper_args=() + bazel_target_list_args=() if [[ "${RUNNER_OS}" == "Windows" ]]; then - # Keep this aligned with the Windows Bazel test job. With the - # default `//:local_windows` host platform, Windows `rust_test` - # targets such as `//codex-rs/core:core-all-test` can be skipped - # by `--skip_incompatible_explicit_targets`, which hides clippy - # diagnostics from integration-test modules. - bazel_wrapper_args+=(--windows-msvc-host-platform) - bazel_clippy_args+=(--skip_incompatible_explicit_targets) + # Keep this aligned with the fast Windows Bazel test job: use + # Linux RBE for clippy build actions while targeting Windows + # gnullvm. Fork/community PRs without the BuildBuddy secret fall + # back inside `run-bazel-ci.sh` to the previous local Windows MSVC + # host-platform shape. + bazel_wrapper_args+=(--windows-cross-compile) + bazel_target_list_args+=(--windows-cross-compile) + if [[ -z "${BUILDBUDDY_API_KEY:-}" ]]; then + # The fork fallback can see incompatible explicit Windows-cross + # internal test binaries in the generated target list. Preserve + # the old local-fallback behavior there. + bazel_clippy_args+=(--skip_incompatible_explicit_targets) + fi fi - bazel_target_lines="$(./scripts/list-bazel-clippy-targets.sh)" + bazel_target_lines="$(./scripts/list-bazel-clippy-targets.sh "${bazel_target_list_args[@]}")" bazel_targets=() while IFS= read -r target; do bazel_targets+=("${target}") @@ -198,7 +295,7 @@ jobs: - name: Upload Bazel execution logs if: always() && !cancelled() continue-on-error: true - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: bazel-execution-logs-clippy-${{ matrix.target }} path: ${{ runner.temp }}/bazel-execution-logs @@ -209,7 +306,7 @@ jobs: - name: Save bazel repository cache if: always() && !cancelled() && steps.prepare_bazel.outputs.repository-cache-hit != 'true' continue-on-error: true - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ${{ steps.prepare_bazel.outputs.repository-cache-path }} key: ${{ steps.prepare_bazel.outputs.repository-cache-key }} @@ -230,7 +327,10 @@ jobs: name: Verify release build on ${{ matrix.os }} for ${{ matrix.target }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Prepare Bazel CI id: prepare_bazel @@ -252,7 +352,12 @@ jobs: # Rust debug assertions explicitly. bazel_wrapper_args=() if [[ "${RUNNER_OS}" == "Windows" ]]; then - bazel_wrapper_args+=(--windows-msvc-host-platform) + # This is build-only signal, so use the same Linux-RBE + # cross-compile path as the fast Windows test and clippy jobs. + # Fork/community PRs without the BuildBuddy secret fall back + # inside `run-bazel-ci.sh` to the previous local Windows MSVC + # host-platform shape. + bazel_wrapper_args+=(--windows-cross-compile) fi bazel_build_args=( @@ -278,10 +383,26 @@ jobs: -- \ "${bazel_targets[@]}" + - name: Verify Bazel builds bwrap + if: runner.os == 'Linux' + env: + BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} + shell: bash + run: | + ./.github/scripts/run-bazel-ci.sh \ + --remote-download-toplevel \ + --print-failed-action-summary \ + -- \ + build \ + --build_metadata=COMMIT_SHA=${GITHUB_SHA} \ + --build_metadata=TAG_job=verify-bwrap \ + -- \ + //codex-rs/bwrap:bwrap + - name: Upload Bazel execution logs if: always() && !cancelled() continue-on-error: true - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: bazel-execution-logs-verify-release-build-${{ matrix.target }} path: ${{ runner.temp }}/bazel-execution-logs @@ -292,7 +413,7 @@ jobs: - name: Save bazel repository cache if: always() && !cancelled() && steps.prepare_bazel.outputs.repository-cache-hit != 'true' continue-on-error: true - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ${{ steps.prepare_bazel.outputs.repository-cache-path }} key: ${{ steps.prepare_bazel.outputs.repository-cache-key }} diff --git a/.github/workflows/blob-size-policy.yml b/.github/workflows/blob-size-policy.yml index b96cb98c30..779198ee02 100644 --- a/.github/workflows/blob-size-policy.yml +++ b/.github/workflows/blob-size-policy.yml @@ -8,17 +8,19 @@ jobs: name: Blob size policy runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 + persist-credentials: false - name: Determine PR comparison range id: range shell: bash run: | set -euo pipefail - echo "base=$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT" - echo "head=$(git rev-parse HEAD^2)" >> "$GITHUB_OUTPUT" + echo "base=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT" + echo "head=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT" - name: Check changed blob sizes env: diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml index 46ecc97dd1..f20d09e112 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -14,7 +14,10 @@ jobs: working-directory: ./codex-rs steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Install Rust toolchain uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb5ed5b5bf..a1c60acc26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,10 @@ jobs: NODE_OPTIONS: --max-old-space-size=4096 steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Verify codex-rs Cargo manifests inherit workspace settings run: python3 .github/scripts/verify_cargo_workspace_manifests.py @@ -29,7 +32,7 @@ jobs: run_install: false - name: Setup Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 22 @@ -52,16 +55,18 @@ jobs: CODEX_VERSION=0.125.0 WORKFLOW_URL="https://github.com/openai/codex/actions/runs/24901475298" OUTPUT_DIR="${RUNNER_TEMP}" + # This reused workflow predates the standalone bwrap artifact. python3 ./scripts/stage_npm_packages.py \ --release-version "$CODEX_VERSION" \ --workflow-url "$WORKFLOW_URL" \ --package codex \ + --allow-missing-native-component bwrap \ --output-dir "$OUTPUT_DIR" PACK_OUTPUT="${OUTPUT_DIR}/codex-npm-${CODEX_VERSION}.tgz" echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT" - name: Upload staged npm package artifact - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: codex-npm-staging path: ${{ steps.stage_npm_package.outputs.pack_output }} diff --git a/.github/workflows/close-stale-contributor-prs.yml b/.github/workflows/close-stale-contributor-prs.yml index 8fb5132772..e8cea8226b 100644 --- a/.github/workflows/close-stale-contributor-prs.yml +++ b/.github/workflows/close-stale-contributor-prs.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Close inactive PRs from contributors - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index 8e9f701eec..aaa15cf40d 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -18,9 +18,12 @@ jobs: steps: - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Annotate locations with typos - uses: codespell-project/codespell-problem-matcher@b80729f885d32f78a716c2f107b4db1025001c42 # v1 + uses: codespell-project/codespell-problem-matcher@b80729f885d32f78a716c2f107b4db1025001c42 # v1.1.0 - name: Codespell uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2 with: diff --git a/.github/workflows/issue-deduplicator.yml b/.github/workflows/issue-deduplicator.yml index 17306d72e1..11b4e914fe 100644 --- a/.github/workflows/issue-deduplicator.yml +++ b/.github/workflows/issue-deduplicator.yml @@ -19,7 +19,9 @@ jobs: reason: ${{ steps.normalize-all.outputs.reason }} has_matches: ${{ steps.normalize-all.outputs.has_matches }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Prepare Codex inputs env: @@ -155,7 +157,9 @@ jobs: reason: ${{ steps.normalize-open.outputs.reason }} has_matches: ${{ steps.normalize-open.outputs.has_matches }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Prepare Codex inputs env: @@ -342,7 +346,7 @@ jobs: issues: write steps: - name: Comment on issue - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: CODEX_OUTPUT: ${{ needs.select-final.outputs.codex_output }} with: diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index 8fbaed5636..a0bba631cd 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -17,7 +17,9 @@ jobs: outputs: codex_output: ${{ steps.codex.outputs.final-message }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - id: codex uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02 # v1.7 @@ -44,7 +46,7 @@ jobs: 6. iOS — Issues with the Codex iOS app. - Additionally add zero or more of the following labels that are relevant to the issue content. Prefer a small set of precise labels over many broad ones. - - For agent-area issues, prefer the most specific applicable label. Use "agent" only as a fallback for agent-related issues that do not fit a more specific agent-area label. Prefer "app-server" over "session" or "config" when the issue is about app-server protocol, API, RPC, schema, launch, or bridge behavior. + - For agent-area issues, prefer the most specific applicable label. Use "agent" only as a fallback for agent-related issues that do not fit a more specific agent-area label. Prefer "app-server" over "session" or "config" when the issue is about app-server protocol, API, RPC, schema, launch, or bridge behavior. Use "memory" for agentic memory storage/retrieval and "performance" for high process memory utilization or memory leaks. 1. windows-os — Bugs or friction specific to Windows environments (always when PowerShell is mentioned, path handling, copy/paste, OS-specific auth or tooling failures). 2. mcp — Topics involving Model Context Protocol servers/clients. 3. mcp-server — Problems related to the codex mcp-server command, where codex runs as an MCP server. @@ -68,7 +70,15 @@ jobs: 21. session - Issues involving session or thread management, including resume, fork, archive, rename/title, thread history, rollout persistence, compaction, checkpoints, retention, and cross-session state. 22. config - Issues involving config.toml, config keys, config key merging, config updates, profiles, hooks config, project config, agent role TOMLs, instruction/personality config, and config schema behavior. 23. plan - Issues involving plan mode, planning workflows, or plan-specific tools/behavior. - 24. agent - Fallback only for core agent loop or agent-related issues that do not fit app-server, connectivity, subagent, session, config, or plan. + 24. computer-use - Issues involving agentic computer use or SkyComputerUseService. + 25. browser - Issues involving agentic browser use, IAB, or the built-in browser within the Codex app. + 26. memory - Issues involving agentic memory storage and retrieval. + 27. imagen - Issues involving image generation. + 28. remote - Issues involving remote access, remote control, or SSH. + 29. performance - Issues involving slow, laggy performance, high memory utilization, or memory leaks. + 30. automations - Issues involving scheduled automation tasks or heartbeats. + 31. pets - Issues involving pets avatars and animations. + 32. agent - Fallback only for core agent loop or agent-related issues that do not fit app-server, connectivity, subagent, session, config, plan, computer-use, browser, memory, imagen, remote, performance, automations, or pets. Issue number: ${{ github.event.issue.number }} diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index 7e4d3a8949..de37faa4ca 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -7,6 +7,11 @@ on: workflow_dispatch: # CI builds in debug (dev) for faster signal. +env: + # Cargo's libgit2 transport has been flaky on macOS when fetching git + # dependencies with nested submodules. Use the system git CLI, which has + # better network/proxy behavior and matches Cargo's own suggested fallback. + CARGO_NET_GIT_FETCH_WITH_CLI: "true" jobs: # --- CI that doesn't need specific targets --------------------------------- @@ -17,7 +22,9 @@ jobs: run: working-directory: codex-rs steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 with: components: rustfmt @@ -31,14 +38,15 @@ jobs: run: working-directory: codex-rs steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 - - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - tool: cargo-shear - version: 1.5.1 + persist-credentials: false + - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 + with: + tool: cargo-shear@1.11.2 - name: cargo shear - run: cargo shear + run: cargo shear --deny-warnings argument_comment_lint_package: name: Argument comment lint package @@ -47,14 +55,16 @@ jobs: CARGO_DYLINT_VERSION: 5.0.0 DYLINT_LINK_VERSION: 5.0.0 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 with: toolchain: nightly-2025-09-18 components: llvm-tools-preview, rustc-dev, rust-src - name: Cache cargo-dylint tooling id: cargo_dylint_cache - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: | ~/.cargo/bin/cargo-dylint @@ -97,7 +107,9 @@ jobs: group: codex-runners labels: codex-windows-x64 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: ./.github/actions/setup-bazel-ci with: target: ${{ runner.os }} @@ -233,7 +245,9 @@ jobs: labels: codex-windows-arm64 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Install Linux build dependencies if: ${{ runner.os == 'Linux' }} shell: bash @@ -276,7 +290,7 @@ jobs: # avoid caching the large target dir on the gnu-dev job. - name: Restore cargo home cache id: cache_cargo_home_restore - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: | ~/.cargo/bin/ @@ -294,7 +308,7 @@ jobs: # Install and restore sccache cache - name: Install sccache if: ${{ env.USE_SCCACHE == 'true' }} - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 + uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 with: tool: sccache version: 0.7.5 @@ -321,7 +335,7 @@ jobs: - name: Restore sccache cache (fallback) if: ${{ env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' }} id: cache_sccache_restore - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ${{ github.workspace }}/.sccache/ key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} @@ -348,7 +362,7 @@ jobs: - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Restore APT cache (musl) id: cache_apt_restore - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: | /var/cache/apt @@ -356,7 +370,7 @@ jobs: - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: version: 0.14.0 @@ -430,7 +444,7 @@ jobs: - name: Install cargo-chef if: ${{ matrix.profile == 'release' }} - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 + uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 with: tool: cargo-chef version: 0.1.71 @@ -449,7 +463,7 @@ jobs: - name: Upload Cargo timings (clippy) if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: cargo-timings-rust-ci-clippy-${{ matrix.target }}-${{ matrix.profile }} path: codex-rs/target/**/cargo-timings/cargo-timing.html @@ -460,7 +474,7 @@ jobs: - name: Save cargo home cache if: always() && !cancelled() && steps.cache_cargo_home_restore.outputs.cache-hit != 'true' continue-on-error: true - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: | ~/.cargo/bin/ @@ -476,7 +490,7 @@ jobs: - name: Save sccache cache (fallback) if: always() && !cancelled() && env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' continue-on-error: true - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ${{ github.workspace }}/.sccache/ key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} @@ -501,7 +515,7 @@ jobs: - name: Save APT cache (musl) if: always() && !cancelled() && (matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl') && steps.cache_apt_restore.outputs.cache-hit != 'true' continue-on-error: true - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: | /var/cache/apt @@ -559,7 +573,9 @@ jobs: labels: codex-windows-arm64 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Install Linux build dependencies if: ${{ runner.os == 'Linux' }} shell: bash @@ -567,7 +583,7 @@ jobs: set -euo pipefail if command -v apt-get >/dev/null 2>&1; then sudo apt-get update -y - sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev bubblewrap fi # Some integration tests rely on DotSlash being installed. @@ -590,7 +606,7 @@ jobs: - name: Restore cargo home cache id: cache_cargo_home_restore - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: | ~/.cargo/bin/ @@ -603,7 +619,7 @@ jobs: - name: Install sccache if: ${{ env.USE_SCCACHE == 'true' }} - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 + uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 with: tool: sccache version: 0.7.5 @@ -630,7 +646,7 @@ jobs: - name: Restore sccache cache (fallback) if: ${{ env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' }} id: cache_sccache_restore - uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ${{ github.workspace }}/.sccache/ key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} @@ -638,7 +654,7 @@ jobs: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}- sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- - - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 + - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 with: tool: nextest version: 0.9.103 @@ -674,7 +690,7 @@ jobs: - name: Upload Cargo timings (nextest) if: always() - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: cargo-timings-rust-ci-nextest-${{ matrix.target }}-${{ matrix.profile }} path: codex-rs/target/**/cargo-timings/cargo-timing.html @@ -683,7 +699,7 @@ jobs: - name: Save cargo home cache if: always() && !cancelled() && steps.cache_cargo_home_restore.outputs.cache-hit != 'true' continue-on-error: true - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: | ~/.cargo/bin/ @@ -695,7 +711,7 @@ jobs: - name: Save sccache cache (fallback) if: always() && !cancelled() && env.USE_SCCACHE == 'true' && env.SCCACHE_GHA_ENABLED != 'true' continue-on-error: true - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: ${{ github.workspace }}/.sccache/ key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ steps.lockhash.outputs.hash }}-${{ github.run_id }} @@ -722,10 +738,12 @@ jobs: shell: bash run: | set +e - if [[ "${{ steps.test.outcome }}" != "success" ]]; then + if [[ "${STEPS_TEST_OUTCOME}" != "success" ]]; then docker logs codex-remote-test-env || true fi docker rm -f codex-remote-test-env >/dev/null 2>&1 || true + env: + STEPS_TEST_OUTCOME: ${{ steps.test.outcome }} - name: verify tests passed if: steps.test.outcome == 'failure' diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 42a3ca8764..75c5c33601 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -14,9 +14,11 @@ jobs: codex: ${{ steps.detect.outputs.codex }} workflows: ${{ steps.detect.outputs.workflows }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 + persist-credentials: false - name: Detect changed paths (no external action) id: detect shell: bash @@ -61,7 +63,10 @@ jobs: run: working-directory: codex-rs steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 with: components: rustfmt @@ -77,14 +82,16 @@ jobs: run: working-directory: codex-rs steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 - - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: - tool: cargo-shear - version: 1.5.1 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 + - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 + with: + tool: cargo-shear@1.11.2 - name: cargo shear - run: cargo shear + run: cargo shear --deny-warnings argument_comment_lint_package: name: Argument comment lint package @@ -95,7 +102,10 @@ jobs: CARGO_DYLINT_VERSION: 5.0.0 DYLINT_LINK_VERSION: 5.0.0 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 - name: Install nightly argument-comment-lint toolchain shell: bash @@ -109,7 +119,7 @@ jobs: rustup default nightly-2025-09-18 - name: Cache cargo-dylint tooling id: cargo_dylint_cache - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: | ~/.cargo/bin/cargo-dylint @@ -170,8 +180,11 @@ jobs: echo "No argument-comment-lint relevant changes." echo "run=false" >> "$GITHUB_OUTPUT" - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 if: ${{ steps.argument_comment_lint_gate.outputs.run == 'true' }} + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Run argument comment lint on codex-rs via Bazel if: ${{ steps.argument_comment_lint_gate.outputs.run == 'true' }} uses: ./.github/actions/run-argument-comment-lint @@ -203,20 +216,25 @@ jobs: # If nothing relevant changed (PR touching only root README, etc.), # declare success regardless of other jobs. - if [[ '${{ needs.changed.outputs.argument_comment_lint }}' != 'true' && '${{ needs.changed.outputs.codex }}' != 'true' && '${{ needs.changed.outputs.workflows }}' != 'true' ]]; then + if [[ "${NEEDS_CHANGED_OUTPUTS_ARGUMENT_COMMENT_LINT}" != 'true' && "${NEEDS_CHANGED_OUTPUTS_CODEX}" != 'true' && "${NEEDS_CHANGED_OUTPUTS_WORKFLOWS}" != 'true' ]]; then echo 'No relevant changes -> CI not required.' exit 0 fi - if [[ '${{ needs.changed.outputs.argument_comment_lint_package }}' == 'true' ]]; then + if [[ "${NEEDS_CHANGED_OUTPUTS_ARGUMENT_COMMENT_LINT_PACKAGE}" == 'true' ]]; then [[ '${{ needs.argument_comment_lint_package.result }}' == 'success' ]] || { echo 'argument_comment_lint_package failed'; exit 1; } fi - if [[ '${{ needs.changed.outputs.argument_comment_lint }}' == 'true' || '${{ needs.changed.outputs.workflows }}' == 'true' ]]; then + if [[ "${NEEDS_CHANGED_OUTPUTS_ARGUMENT_COMMENT_LINT}" == 'true' || "${NEEDS_CHANGED_OUTPUTS_WORKFLOWS}" == 'true' ]]; then [[ '${{ needs.argument_comment_lint_prebuilt.result }}' == 'success' ]] || { echo 'argument_comment_lint_prebuilt failed'; exit 1; } fi - if [[ '${{ needs.changed.outputs.codex }}' == 'true' || '${{ needs.changed.outputs.workflows }}' == 'true' ]]; then + if [[ "${NEEDS_CHANGED_OUTPUTS_CODEX}" == 'true' || "${NEEDS_CHANGED_OUTPUTS_WORKFLOWS}" == 'true' ]]; then [[ '${{ needs.general.result }}' == 'success' ]] || { echo 'general failed'; exit 1; } [[ '${{ needs.cargo_shear.result }}' == 'success' ]] || { echo 'cargo_shear failed'; exit 1; } fi + env: + NEEDS_CHANGED_OUTPUTS_ARGUMENT_COMMENT_LINT: ${{ needs.changed.outputs.argument_comment_lint }} + NEEDS_CHANGED_OUTPUTS_CODEX: ${{ needs.changed.outputs.codex }} + NEEDS_CHANGED_OUTPUTS_WORKFLOWS: ${{ needs.changed.outputs.workflows }} + NEEDS_CHANGED_OUTPUTS_ARGUMENT_COMMENT_LINT_PACKAGE: ${{ needs.changed.outputs.argument_comment_lint_package }} diff --git a/.github/workflows/rust-release-argument-comment-lint.yml b/.github/workflows/rust-release-argument-comment-lint.yml index ba0d147d4f..f654bd9dd7 100644 --- a/.github/workflows/rust-release-argument-comment-lint.yml +++ b/.github/workflows/rust-release-argument-comment-lint.yml @@ -56,7 +56,9 @@ jobs: labels: codex-windows-x64 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 with: @@ -100,7 +102,7 @@ jobs: (cd "${RUNNER_TEMP}" && tar -czf "$GITHUB_WORKSPACE/$archive_path" argument-comment-lint) fi - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: argument-comment-lint-${{ matrix.target }} path: dist/argument-comment-lint/${{ matrix.target }}/* diff --git a/.github/workflows/rust-release-prepare.yml b/.github/workflows/rust-release-prepare.yml index 528c329d58..e998efb54c 100644 --- a/.github/workflows/rust-release-prepare.yml +++ b/.github/workflows/rust-release-prepare.yml @@ -18,10 +18,11 @@ jobs: if: github.repository == 'openai/codex' runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: main fetch-depth: 0 + persist-credentials: false - name: Update models.json env: @@ -43,7 +44,7 @@ jobs: curl --http1.1 --fail --show-error --location "${headers[@]}" "${url}" | jq '.' > codex-rs/models-manager/models.json - name: Open pull request (if changed) - uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8 + uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v8.1.0 with: commit-message: "Update models.json" title: "Update models.json" diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 5e1edae173..8a1b6030a8 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -83,7 +83,9 @@ jobs: labels: codex-windows-arm64 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Print runner specs (Windows) shell: powershell run: | @@ -112,7 +114,7 @@ jobs: cargo build --target ${{ matrix.target }} --release --timings "${build_args[@]}" - name: Upload Cargo timings - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: cargo-timings-rust-release-windows-${{ matrix.target }}-${{ matrix.bundle }} path: codex-rs/target/**/cargo-timings/cargo-timing.html @@ -128,7 +130,7 @@ jobs: done - name: Upload Windows binaries - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: windows-binaries-${{ matrix.target }}-${{ matrix.bundle }} path: | @@ -165,22 +167,24 @@ jobs: labels: codex-windows-arm64 steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Download prebuilt Windows primary binaries - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: windows-binaries-${{ matrix.target }}-primary path: codex-rs/target/${{ matrix.target }}/release - name: Download prebuilt Windows helper binaries - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: windows-binaries-${{ matrix.target }}-helpers path: codex-rs/target/${{ matrix.target }}/release - name: Download prebuilt Windows app-server binary - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: windows-binaries-${{ matrix.target }}-app-server path: codex-rs/target/${{ matrix.target }}/release @@ -216,6 +220,48 @@ jobs: "$dest/${binary}-${{ matrix.target }}.exe" done + - name: Build Python runtime wheel + shell: bash + run: | + set -euo pipefail + + case "${{ matrix.target }}" in + aarch64-pc-windows-msvc) + platform_tag="win_arm64" + ;; + x86_64-pc-windows-msvc) + platform_tag="win_amd64" + ;; + *) + echo "No Python runtime wheel platform tag for ${{ matrix.target }}" + exit 1 + ;; + esac + + python -m venv "${RUNNER_TEMP}/python-runtime-build-venv" + "${RUNNER_TEMP}/python-runtime-build-venv/Scripts/python.exe" -m pip install build + + stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" + wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" + # Keep the helpers next to codex.exe in the runtime wheel so Windows + # sandbox/elevation lookup matches the standalone release zip. + python "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" \ + stage-runtime \ + "$stage_dir" \ + "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex.exe" \ + --codex-version "${GITHUB_REF_NAME}" \ + --platform-tag "$platform_tag" \ + --resource-binary "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex-command-runner.exe" \ + --resource-binary "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe" + "${RUNNER_TEMP}/python-runtime-build-venv/Scripts/python.exe" -m build --wheel --outdir "$wheel_dir" "$stage_dir" + + - name: Upload Python runtime wheel + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: python-runtime-wheel-${{ matrix.target }} + path: python-runtime-dist/${{ matrix.target }}/*.whl + if-no-files-found: error + - name: Install DotSlash uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 @@ -281,7 +327,7 @@ jobs: "${GITHUB_WORKSPACE}/.github/workflows/zstd" -T0 -19 "$dest/$base" done - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: ${{ matrix.target }} path: | diff --git a/.github/workflows/rust-release-zsh.yml b/.github/workflows/rust-release-zsh.yml index 7ec49f9863..492b8dc5e7 100644 --- a/.github/workflows/rust-release-zsh.yml +++ b/.github/workflows/rust-release-zsh.yml @@ -45,7 +45,9 @@ jobs: git \ libncursesw5-dev - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Build, smoke-test, and stage zsh artifact shell: bash @@ -53,7 +55,7 @@ jobs: "${GITHUB_WORKSPACE}/.github/scripts/build-zsh-release-artifact.sh" \ "dist/zsh/${{ matrix.target }}/${{ matrix.archive_name }}" - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: codex-zsh-${{ matrix.target }} path: dist/zsh/${{ matrix.target }}/* @@ -81,7 +83,9 @@ jobs: brew install autoconf fi - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Build, smoke-test, and stage zsh artifact shell: bash @@ -89,7 +93,7 @@ jobs: "${GITHUB_WORKSPACE}/.github/scripts/build-zsh-release-artifact.sh" \ "dist/zsh/${{ matrix.target }}/${{ matrix.archive_name }}" - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: codex-zsh-${{ matrix.target }} path: dist/zsh/${{ matrix.target }}/* diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index faab87f97b..6dd751b7f6 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -19,7 +19,9 @@ jobs: tag-check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 - name: Validate tag matches Cargo.toml version shell: bash @@ -96,7 +98,7 @@ jobs: target: x86_64-unknown-linux-musl bundle: primary artifact_name: x86_64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy" + binaries: "codex codex-responses-api-proxy bwrap" build_dmg: "false" - runner: ubuntu-24.04 target: x86_64-unknown-linux-musl @@ -108,7 +110,7 @@ jobs: target: aarch64-unknown-linux-musl bundle: primary artifact_name: aarch64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy" + binaries: "codex codex-responses-api-proxy bwrap" build_dmg: "false" - runner: ubuntu-24.04-arm target: aarch64-unknown-linux-musl @@ -118,7 +120,9 @@ jobs: build_dmg: "false" steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Print runner specs (Linux) if: ${{ runner.os == 'Linux' }} shell: bash @@ -181,9 +185,10 @@ jobs: - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install Zig - uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2 + uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1 with: version: 0.14.0 + use-cache: false - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools @@ -255,6 +260,24 @@ jobs: with: target: ${{ matrix.target }} + - if: ${{ contains(matrix.target, 'linux') && matrix.bundle == 'primary' }} + name: Build bwrap and export digest + shell: bash + run: | + set -euo pipefail + target="${{ matrix.target }}" + cargo build --target "$target" --release --timings --bin bwrap + + bwrap_path="target/${target}/release/bwrap" + if [[ ! -f "$bwrap_path" ]]; then + echo "bwrap binary ${bwrap_path} not found" + exit 1 + fi + + digest="$(sha256sum "$bwrap_path" | awk '{print $1}')" + echo "CODEX_BWRAP_SHA256=${digest}" >> "$GITHUB_ENV" + echo "Built bwrap ${bwrap_path} with sha256:${digest}" + - name: Cargo build shell: bash run: | @@ -266,7 +289,7 @@ jobs: cargo build --target ${{ matrix.target }} --release --timings "${build_args[@]}" - name: Upload Cargo timings - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: cargo-timings-rust-release-${{ matrix.target }}-${{ matrix.bundle }} path: codex-rs/target/**/cargo-timings/cargo-timing.html @@ -361,10 +384,80 @@ jobs: fi done + if [[ "${{ matrix.target }}" == *linux* && "${{ matrix.bundle }}" == "primary" ]]; then + bundle_root="${RUNNER_TEMP}/codex-${{ matrix.target }}-bundle" + rm -rf "$bundle_root" + mkdir -p "$bundle_root/codex-resources" + cp "$dest/codex-${{ matrix.target }}" "$bundle_root/codex" + cp "$dest/bwrap-${{ matrix.target }}" "$bundle_root/codex-resources/bwrap" + chmod 0755 "$bundle_root/codex" "$bundle_root/codex-resources/bwrap" + tar -C "$bundle_root" -cf - codex codex-resources/bwrap | + zstd -T0 -19 -o "$dest/codex-${{ matrix.target }}-bundle.tar.zst" + fi + if [[ "${{ matrix.build_dmg }}" == "true" ]]; then cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi + - name: Build Python runtime wheel + if: ${{ matrix.bundle == 'primary' }} + shell: bash + run: | + set -euo pipefail + + case "${{ matrix.target }}" in + aarch64-apple-darwin) + platform_tag="macosx_11_0_arm64" + ;; + x86_64-apple-darwin) + platform_tag="macosx_10_9_x86_64" + ;; + aarch64-unknown-linux-musl) + platform_tag="musllinux_1_1_aarch64" + ;; + x86_64-unknown-linux-musl) + platform_tag="musllinux_1_1_x86_64" + ;; + *) + echo "No Python runtime wheel platform tag for ${{ matrix.target }}" + exit 1 + ;; + esac + + python3 -m venv "${RUNNER_TEMP}/python-runtime-build-venv" + # Do not install into the runner's system Python; macOS runners mark + # the Homebrew Python as externally managed under PEP 668. + "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m pip install build + + stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" + wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" + stage_runtime_args=( + "${GITHUB_WORKSPACE}/sdk/python/scripts/update_sdk_artifacts.py" + stage-runtime + "$stage_dir" + "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/codex" + --codex-version "${GITHUB_REF_NAME}" + --platform-tag "$platform_tag" + ) + if [[ "${{ matrix.target }}" == *linux* ]]; then + # Keep bwrap in the runtime wheel so Linux sandbox fallback behavior + # matches the standalone release bundle on hosts without system bwrap. + stage_runtime_args+=( + --resource-binary + "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/bwrap" + ) + fi + python3 "${stage_runtime_args[@]}" + "${RUNNER_TEMP}/python-runtime-build-venv/bin/python" -m build --wheel --outdir "$wheel_dir" "$stage_dir" + + - name: Upload Python runtime wheel + if: ${{ matrix.bundle == 'primary' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: python-runtime-wheel-${{ matrix.target }} + path: python-runtime-dist/${{ matrix.target }}/*.whl + if-no-files-found: error + - name: Compress artifacts shell: bash run: | @@ -384,7 +477,7 @@ jobs: base="$(basename "$f")" # Skip files that are already archives (shouldn't happen, but be # safe). - if [[ "$base" == *.tar.gz || "$base" == *.zip || "$base" == *.dmg ]]; then + if [[ "$base" == *.tar.gz || "$base" == *.tar.zst || "$base" == *.zip || "$base" == *.dmg ]]; then continue fi @@ -401,11 +494,11 @@ jobs: zstd -T0 -19 --rm "$dest/$base" done - - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: ${{ matrix.artifact_name }} - # Upload the per-binary .zst files as well as the new .tar.gz - # equivalents we generated in the previous step. + # Upload the per-binary .zst files, .tar.gz equivalents, and any + # prebuilt archives staged above. path: | codex-rs/dist/${{ matrix.target }}/* @@ -444,10 +537,13 @@ jobs: tag: ${{ github.ref_name }} should_publish_npm: ${{ steps.npm_publish_settings.outputs.should_publish }} npm_tag: ${{ steps.npm_publish_settings.outputs.npm_tag }} + should_publish_python_runtime: ${{ steps.python_runtime_publish_settings.outputs.should_publish }} steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Generate release notes from tag commit message id: release_notes @@ -469,7 +565,7 @@ jobs: echo "path=${notes_path}" >> "${GITHUB_OUTPUT}" - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: dist @@ -518,13 +614,29 @@ jobs: echo "npm_tag=" >> "$GITHUB_OUTPUT" fi + - name: Determine Python runtime publish settings + id: python_runtime_publish_settings + env: + VERSION: ${{ steps.release_name.outputs.name }} + run: | + set -euo pipefail + version="${VERSION}" + + if [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "should_publish=true" >> "$GITHUB_OUTPUT" + elif [[ "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then + echo "should_publish=true" >> "$GITHUB_OUTPUT" + else + echo "should_publish=false" >> "$GITHUB_OUTPUT" + fi + - name: Setup pnpm uses: pnpm/action-setup@a8198c4bff370c8506180b035930dea56dbd5288 # v5 with: run_install: false - name: Setup Node.js for npm packaging - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 22 @@ -550,7 +662,7 @@ jobs: cp scripts/install/install.ps1 dist/install.ps1 - name: Create GitHub Release - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 with: name: ${{ steps.release_name.outputs.name }} tag_name: ${{ github.ref_name }} @@ -609,7 +721,7 @@ jobs: steps: - name: Setup Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: # Node 24 bundles npm >= 11.5.1, which trusted publishing requires. node-version: 24 @@ -751,6 +863,48 @@ jobs: exit "${publish_status}" done + # Publish the platform-specific Python runtime wheels using PyPI trusted publishing. + # PyPI project configuration must trust this workflow and job. Keep this + # non-blocking while the Python runtime publishing path is new; failures still + # need release follow-up, but should not invalidate the Rust release itself. + publish-python-runtime: + # Publish to PyPI for stable releases and alpha pre-releases with numeric suffixes. + if: ${{ needs.release.outputs.should_publish_python_runtime == 'true' }} + name: publish-python-runtime + needs: release + runs-on: ubuntu-latest + continue-on-error: true + environment: pypi + permissions: + id-token: write # Required for PyPI trusted publishing. + contents: read + + steps: + - name: Download Python runtime wheels from release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ needs.release.outputs.tag }} + RELEASE_VERSION: ${{ needs.release.outputs.version }} + run: | + set -euo pipefail + python_version="$RELEASE_VERSION" + python_version="${python_version/-alpha./a}" + python_version="${python_version/-beta./b}" + python_version="${python_version/-rc./rc}" + + mkdir -p dist/python-runtime + gh release download "$RELEASE_TAG" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "openai_codex_cli_bin-${python_version}-*.whl" \ + --dir dist/python-runtime + ls -lh dist/python-runtime + + - name: Publish Python runtime wheels to PyPI + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 + with: + packages-dir: dist/python-runtime + skip-existing: true + winget: name: winget needs: release diff --git a/.github/workflows/rusty-v8-release.yml b/.github/workflows/rusty-v8-release.yml index ee92eff4fa..5a436722bb 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -1,20 +1,12 @@ name: rusty-v8-release on: - workflow_dispatch: - inputs: - release_tag: - description: Optional release tag. Defaults to rusty-v8-v. - required: false - type: string - publish: - description: Publish the staged musl artifacts to a GitHub release. - required: false - default: true - type: boolean + push: + tags: + - "rusty-v8-v*.*.*" concurrency: - group: ${{ github.workflow }}::${{ inputs.release_tag || github.run_id }} + group: ${{ github.workflow }}::${{ github.ref_name }} cancel-in-progress: false jobs: @@ -25,10 +17,12 @@ jobs: v8_version: ${{ steps.v8_version.outputs.version }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.12" @@ -43,15 +37,17 @@ jobs: - name: Resolve release tag id: release_tag env: - RELEASE_TAG_INPUT: ${{ inputs.release_tag }} + GITHUB_REF_NAME: ${{ github.ref_name }} V8_VERSION: ${{ steps.v8_version.outputs.version }} shell: bash run: | set -euo pipefail - release_tag="${RELEASE_TAG_INPUT}" - if [[ -z "${release_tag}" ]]; then - release_tag="rusty-v8-v${V8_VERSION}" + expected_release_tag="rusty-v8-v${V8_VERSION}" + release_tag="${GITHUB_REF_NAME}" + if [[ "${release_tag}" != "${expected_release_tag}" ]]; then + echo "Tag ${release_tag} does not match resolved v8 crate version ${V8_VERSION}." >&2 + exit 1 fi echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT" @@ -75,7 +71,9 @@ jobs: target: aarch64-unknown-linux-musl steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Set up Bazel uses: ./.github/actions/setup-bazel-ci @@ -83,7 +81,7 @@ jobs: target: ${{ matrix.target }} - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.12" @@ -111,6 +109,7 @@ jobs: -c opt "--platforms=@llvm//platforms:${PLATFORM}" + --config=v8-release-compat "${pair_target}" "${extra_targets[@]}" --build_metadata=COMMIT_SHA=$(git rev-parse HEAD) @@ -134,16 +133,16 @@ jobs: --platform "${PLATFORM}" \ --target "${TARGET}" \ --compilation-mode opt \ + --bazel-config v8-release-compat \ --output-dir "dist/${TARGET}" - name: Upload staged musl artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: rusty-v8-${{ needs.metadata.outputs.v8_version }}-${{ matrix.target }} path: dist/${{ matrix.target }}/* publish-release: - if: ${{ inputs.publish }} needs: - metadata - build @@ -153,16 +152,6 @@ jobs: actions: read steps: - - name: Ensure publishing from default branch - if: ${{ github.ref_name != github.event.repository.default_branch }} - env: - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - shell: bash - run: | - set -euo pipefail - echo "Publishing is only allowed from ${DEFAULT_BRANCH}; current ref is ${GITHUB_REF_NAME}." >&2 - exit 1 - - name: Ensure release tag is new env: GH_TOKEN: ${{ github.token }} @@ -176,12 +165,12 @@ jobs: exit 1 fi - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: dist - name: Create GitHub Release - uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2 + uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1 with: tag_name: ${{ needs.metadata.outputs.release_tag }} name: ${{ needs.metadata.outputs.release_tag }} diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 45c983ac1e..54ed8dc558 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -6,6 +6,41 @@ on: pull_request: {} jobs: + python-sdk: + runs-on: + group: codex-runners + labels: codex-linux-x64 + timeout-minutes: 10 + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Test Python SDK + shell: bash + run: | + set -euo pipefail + + # Run inside Alpine so dependency resolution exercises the pinned + # runtime wheel on the same Linux wheel family that CI installs. + docker run --rm \ + --user "$(id -u):$(id -g)" \ + -e HOME=/tmp/codex-python-sdk-home \ + -e UV_LINK_MODE=copy \ + -v "${GITHUB_WORKSPACE}:${GITHUB_WORKSPACE}" \ + -w "${GITHUB_WORKSPACE}/sdk/python" \ + python:3.12-alpine \ + sh -euxc ' + python -m venv /tmp/uv + /tmp/uv/bin/python -m pip install uv==0.11.3 + /tmp/uv/bin/uv sync --extra dev --frozen + /tmp/uv/bin/uv run --extra dev ruff check --output-format=github . + /tmp/uv/bin/uv run --extra dev ruff format --check . + /tmp/uv/bin/uv run --extra dev pytest + ' + sdks: runs-on: group: codex-runners @@ -13,7 +48,10 @@ jobs: timeout-minutes: 10 steps: - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Install Linux bwrap build dependencies shell: bash @@ -28,7 +66,7 @@ jobs: run_install: false - name: Setup Node.js - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 22 cache: pnpm @@ -115,7 +153,7 @@ jobs: - name: Save bazel repository cache if: always() && !cancelled() && steps.setup_bazel.outputs.cache-hit != 'true' continue-on-error: true - uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5 + uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: path: | ~/.cache/bazel-repo-cache diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 119d042275..6e71367d1f 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -40,10 +40,13 @@ jobs: v8_version: ${{ steps.v8_version.outputs.version }} steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.12" @@ -74,7 +77,10 @@ jobs: target: aarch64-unknown-linux-musl steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Set up Bazel uses: ./.github/actions/setup-bazel-ci @@ -82,7 +88,7 @@ jobs: target: ${{ matrix.target }} - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.12" @@ -105,6 +111,7 @@ jobs: bazel_args=( build "--platforms=@llvm//platforms:${PLATFORM}" + --config=v8-release-compat "${pair_target}" "${extra_targets[@]}" --build_metadata=COMMIT_SHA=$(git rev-parse HEAD) @@ -127,10 +134,11 @@ jobs: python3 .github/scripts/rusty_v8_bazel.py stage-release-pair \ --platform "${PLATFORM}" \ --target "${TARGET}" \ + --bazel-config v8-release-compat \ --output-dir "dist/${TARGET}" - name: Upload staged musl artifacts - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: v8-canary-${{ needs.metadata.outputs.v8_version }}-${{ matrix.target }} path: dist/${{ matrix.target }}/* diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 8d6532aa7c..e92864a68a 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,6 +1,7 @@ { "recommendations": [ "rust-lang.rust-analyzer", + "charliermarsh.ruff", "tamasfe.even-better-toml", "vadimcn.vscode-lldb", diff --git a/.vscode/settings.json b/.vscode/settings.json index 1e857367cb..d19746aec4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -12,6 +12,14 @@ "editor.defaultFormatter": "tamasfe.even-better-toml", "editor.formatOnSave": true, }, + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.fixAll.ruff": "explicit", + "source.organizeImports.ruff": "explicit", + }, + }, // Array order for options in ~/.codex/config.toml such as `notify` and the // `args` for an MCP server is significant, so we disable reordering. "evenBetterToml.formatter.reorderArrays": false, diff --git a/AGENTS.md b/AGENTS.md index 6939d146b0..c13fdea641 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ In the codex-rs folder where the rust code lives: - Implementations may still use `async fn foo(&self, ...) -> T` when they satisfy that contract. - Do not use `#[allow(async_fn_in_trait)]` as a shortcut around spelling the future contract explicitly. - 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. +- Do not add general product or user-facing documentation to the `docs/` folder. The official Codex documentation lives elsewhere. The exception is app-server API documentation, which is covered by the app-server guidance below. - Prefer private modules and explicitly exported public crate API. - If you change `ConfigToml` or nested config types, run `just write-config-schema` to update `codex-rs/core/config.schema.json`. - When working with MCP tool calls, prefer using `codex-rs/codex-mcp/src/mcp_connection_manager.rs` to handle mutation of tools and tool calls. Aim to minimize the footprint of changes and leverage existing abstractions rather than plumbing code through multiple levels of function calls. @@ -130,7 +130,7 @@ When UI or text output changes intentionally, update the snapshots as follows: If you don’t have the tool: -- `cargo install cargo-insta` +- `cargo install --locked cargo-insta` ### Test assertions @@ -210,7 +210,7 @@ These guidelines apply to app-server protocol work in `codex-rs`, especially: ### Development Workflow -- Update docs/examples when API behavior changes (at minimum `app-server/README.md`). +- Update app-server docs/examples when API behavior changes (at minimum `app-server/README.md`). - Regenerate schema fixtures when API shapes change: `just write-app-server-schema` (and `just write-app-server-schema --experimental` when experimental API fixtures are affected). diff --git a/BUILD.bazel b/BUILD.bazel index 3f59ff1160..a82126e6f1 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -30,6 +30,40 @@ platform( parents = ["@platforms//host"], ) +platform( + name = "windows_x86_64_gnullvm", + constraint_values = [ + "@platforms//cpu:x86_64", + "@platforms//os:windows", + "@rules_rs//rs/experimental/platforms/constraints:windows_gnullvm", + ], +) + +platform( + name = "windows_x86_64_msvc", + constraint_values = [ + "@platforms//cpu:x86_64", + "@platforms//os:windows", + "@rules_rs//rs/experimental/platforms/constraints:windows_msvc", + ], +) + +toolchain( + name = "windows_gnullvm_tests_on_msvc_host_toolchain", + 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_gnullvm", + ], + toolchain = "@bazel_tools//tools/test:empty_toolchain", + toolchain_type = "@bazel_tools//tools/test:default_test_toolchain_type", +) + alias( name = "rbe", actual = "@rbe_platform", diff --git a/MODULE.bazel b/MODULE.bazel index be00937946..f675038791 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -327,6 +327,18 @@ crate.annotation( "RUSTY_V8_SRC_BINDING_PATH": "$(execpath @v8_targets//:rusty_v8_binding_for_target)", }, crate = "v8", + # Keep the Rust feature aligned with the source-built Bazel artifacts. + # Windows MSVC still consumes upstream non-sandboxed prebuilts. + crate_features_select = { + "aarch64-apple-darwin": ["v8_enable_sandbox"], + "aarch64-pc-windows-gnullvm": ["v8_enable_sandbox"], + "aarch64-unknown-linux-gnu": ["v8_enable_sandbox"], + "aarch64-unknown-linux-musl": ["v8_enable_sandbox"], + "x86_64-apple-darwin": ["v8_enable_sandbox"], + "x86_64-pc-windows-gnullvm": ["v8_enable_sandbox"], + "x86_64-unknown-linux-gnu": ["v8_enable_sandbox"], + "x86_64-unknown-linux-musl": ["v8_enable_sandbox"], + }, gen_build_script = "on", patch_args = ["-p1"], patches = [ diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index e079e3af0e..ce362f0978 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -665,6 +665,7 @@ "aws-lc-rs_1.16.2": "{\"dependencies\":[{\"name\":\"aws-lc-fips-sys\",\"optional\":true,\"req\":\"^0.13.1\"},{\"default_features\":false,\"name\":\"aws-lc-sys\",\"optional\":true,\"req\":\"^0.39.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.4\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4.3\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"paste\",\"req\":\"^1.0.15\"},{\"kind\":\"dev\",\"name\":\"regex\",\"req\":\"^1.11.1\"},{\"name\":\"untrusted\",\"optional\":true,\"req\":\"^0.7.1\"},{\"name\":\"zeroize\",\"req\":\"^1.8.1\"}],\"features\":{\"alloc\":[],\"asan\":[\"aws-lc-sys?/asan\",\"aws-lc-fips-sys?/asan\"],\"bindgen\":[\"aws-lc-sys?/bindgen\",\"aws-lc-fips-sys?/bindgen\"],\"default\":[\"aws-lc-sys\",\"alloc\",\"ring-io\",\"ring-sig-verify\"],\"dev-tests-only\":[],\"fips\":[\"dep:aws-lc-fips-sys\"],\"non-fips\":[\"aws-lc-sys\"],\"prebuilt-nasm\":[\"aws-lc-sys?/prebuilt-nasm\"],\"ring-io\":[\"dep:untrusted\"],\"ring-sig-verify\":[\"dep:untrusted\"],\"test_logging\":[],\"unstable\":[]}}", "aws-lc-sys_0.39.0": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72.0\"},{\"features\":[\"parallel\"],\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.26\"},{\"kind\":\"build\",\"name\":\"cmake\",\"req\":\"^0.1.54\"},{\"kind\":\"build\",\"name\":\"dunce\",\"req\":\"^1.0.5\"},{\"kind\":\"build\",\"name\":\"fs_extra\",\"req\":\"^1.3.0\"}],\"features\":{\"all-bindings\":[],\"asan\":[],\"bindgen\":[\"dep:bindgen\"],\"default\":[\"all-bindings\"],\"disable-prebuilt-nasm\":[],\"fips\":[\"dep:bindgen\"],\"prebuilt-nasm\":[],\"ssl\":[\"bindgen\",\"all-bindings\"]}}", "aws-runtime_1.5.17": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"arbitrary\",\"req\":\"^1.3\"},{\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"features\":[\"http0-compat\"],\"name\":\"aws-sigv4\",\"req\":\"^1.3.7\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-eventstream\",\"optional\":true,\"req\":\"^0.60.14\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.62.6\"},{\"kind\":\"dev\",\"name\":\"aws-smithy-protocol-test\",\"req\":\"^0.63.7\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"aws-types\",\"req\":\"^1.3.11\"},{\"name\":\"bytes\",\"req\":\"^1.10.0\"},{\"kind\":\"dev\",\"name\":\"bytes-utils\",\"req\":\"^0.1.2\"},{\"kind\":\"dev\",\"name\":\"convert_case\",\"req\":\"^0.6.0\"},{\"name\":\"fastrand\",\"req\":\"^2.3.0\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.29\"},{\"name\":\"http-02x\",\"package\":\"http\",\"req\":\"^0.2.9\"},{\"name\":\"http-1x\",\"optional\":true,\"package\":\"http\",\"req\":\"^1.1.0\"},{\"name\":\"http-body-04x\",\"package\":\"http-body\",\"req\":\"^0.4.5\"},{\"name\":\"http-body-1x\",\"optional\":true,\"package\":\"http-body\",\"req\":\"^1.0.0\"},{\"name\":\"percent-encoding\",\"req\":\"^2.3.1\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2.14\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1.2\"},{\"name\":\"regex-lite\",\"optional\":true,\"req\":\"^0.1.5\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1\"},{\"features\":[\"macros\",\"rt\",\"time\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"tracing\",\"req\":\"^0.1.40\"},{\"features\":[\"env-filter\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.17\"},{\"kind\":\"dev\",\"name\":\"tracing-test\",\"req\":\"^0.2.4\"},{\"name\":\"uuid\",\"req\":\"^1\"}],\"features\":{\"event-stream\":[\"dep:aws-smithy-eventstream\",\"aws-sigv4/sign-eventstream\"],\"http-02x\":[],\"http-1x\":[\"dep:http-1x\",\"dep:http-body-1x\"],\"sigv4a\":[\"aws-sigv4/sigv4a\"],\"test-util\":[\"dep:regex-lite\"]}}", + "aws-sdk-signin_1.2.0": "{\"dependencies\":[{\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"name\":\"aws-runtime\",\"req\":\"^1.5.17\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.62.6\"},{\"name\":\"aws-smithy-json\",\"req\":\"^0.61.8\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"client\",\"http-02x\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"aws-types\",\"req\":\"^1.3.11\"},{\"name\":\"bytes\",\"req\":\"^1.4.0\"},{\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"http\",\"req\":\"^0.2.9\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"regex-lite\",\"req\":\"^0.1.5\"},{\"features\":[\"macros\",\"test-util\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"behavior-version-latest\":[],\"default\":[\"rustls\",\"default-https-client\",\"rt-tokio\"],\"default-https-client\":[\"aws-smithy-runtime/default-https-client\"],\"gated-tests\":[],\"rt-tokio\":[\"aws-smithy-async/rt-tokio\",\"aws-smithy-types/rt-tokio\"],\"rustls\":[\"aws-smithy-runtime/tls-rustls\"],\"test-util\":[\"aws-credential-types/test-util\",\"aws-smithy-runtime/test-util\"]}}", "aws-sdk-sso_1.91.0": "{\"dependencies\":[{\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"name\":\"aws-runtime\",\"req\":\"^1.5.17\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.62.6\"},{\"name\":\"aws-smithy-json\",\"req\":\"^0.61.8\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"client\",\"http-02x\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"aws-types\",\"req\":\"^1.3.11\"},{\"name\":\"bytes\",\"req\":\"^1.4.0\"},{\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"http\",\"req\":\"^0.2.9\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"regex-lite\",\"req\":\"^0.1.5\"},{\"features\":[\"macros\",\"test-util\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"behavior-version-latest\":[],\"default\":[\"rustls\",\"default-https-client\",\"rt-tokio\"],\"default-https-client\":[\"aws-smithy-runtime/default-https-client\"],\"gated-tests\":[],\"rt-tokio\":[\"aws-smithy-async/rt-tokio\",\"aws-smithy-types/rt-tokio\"],\"rustls\":[\"aws-smithy-runtime/tls-rustls\"],\"test-util\":[\"aws-credential-types/test-util\",\"aws-smithy-runtime/test-util\"]}}", "aws-sdk-ssooidc_1.93.0": "{\"dependencies\":[{\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"name\":\"aws-runtime\",\"req\":\"^1.5.17\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.62.6\"},{\"name\":\"aws-smithy-json\",\"req\":\"^0.61.8\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"client\",\"http-02x\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"aws-types\",\"req\":\"^1.3.11\"},{\"name\":\"bytes\",\"req\":\"^1.4.0\"},{\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"name\":\"http\",\"req\":\"^0.2.9\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"regex-lite\",\"req\":\"^0.1.5\"},{\"features\":[\"macros\",\"test-util\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"tracing\",\"req\":\"^0.1\"}],\"features\":{\"behavior-version-latest\":[],\"default\":[\"rustls\",\"default-https-client\",\"rt-tokio\"],\"default-https-client\":[\"aws-smithy-runtime/default-https-client\"],\"gated-tests\":[],\"rt-tokio\":[\"aws-smithy-async/rt-tokio\",\"aws-smithy-types/rt-tokio\"],\"rustls\":[\"aws-smithy-runtime/tls-rustls\"],\"test-util\":[\"aws-credential-types/test-util\",\"aws-smithy-runtime/test-util\"]}}", "aws-sdk-sts_1.95.0": "{\"dependencies\":[{\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-credential-types\",\"req\":\"^1.2.11\"},{\"name\":\"aws-runtime\",\"req\":\"^1.5.17\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-runtime\",\"req\":\"^1.5.17\"},{\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-async\",\"req\":\"^1.2.7\"},{\"name\":\"aws-smithy-http\",\"req\":\"^0.62.6\"},{\"features\":[\"test-util\",\"wire-mock\"],\"kind\":\"dev\",\"name\":\"aws-smithy-http-client\",\"req\":\"^1.1.5\"},{\"name\":\"aws-smithy-json\",\"req\":\"^0.61.8\"},{\"kind\":\"dev\",\"name\":\"aws-smithy-protocol-test\",\"req\":\"^0.63.7\"},{\"name\":\"aws-smithy-query\",\"req\":\"^0.60.9\"},{\"features\":[\"client\"],\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime\",\"req\":\"^1.9.5\"},{\"features\":[\"client\",\"http-02x\"],\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-runtime-api\",\"req\":\"^1.9.3\"},{\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"features\":[\"test-util\"],\"kind\":\"dev\",\"name\":\"aws-smithy-types\",\"req\":\"^1.3.5\"},{\"name\":\"aws-smithy-xml\",\"req\":\"^0.60.13\"},{\"name\":\"aws-types\",\"req\":\"^1.3.11\"},{\"name\":\"fastrand\",\"req\":\"^2.0.0\"},{\"default_features\":false,\"features\":[\"alloc\"],\"kind\":\"dev\",\"name\":\"futures-util\",\"req\":\"^0.3.25\"},{\"name\":\"http\",\"req\":\"^0.2.9\"},{\"kind\":\"dev\",\"name\":\"http-1x\",\"package\":\"http\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"proptest\",\"req\":\"^1\"},{\"name\":\"regex-lite\",\"req\":\"^0.1.5\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.0\"},{\"features\":[\"macros\",\"test-util\",\"rt-multi-thread\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1.23.1\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"json\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3.16\"}],\"features\":{\"behavior-version-latest\":[],\"default\":[\"rustls\",\"default-https-client\",\"rt-tokio\"],\"default-https-client\":[\"aws-smithy-runtime/default-https-client\"],\"gated-tests\":[],\"rt-tokio\":[\"aws-smithy-async/rt-tokio\",\"aws-smithy-types/rt-tokio\"],\"rustls\":[\"aws-smithy-runtime/tls-rustls\"],\"test-util\":[\"aws-credential-types/test-util\",\"aws-smithy-runtime/test-util\"]}}", @@ -886,7 +887,6 @@ "enum-as-inner_0.6.1": "{\"dependencies\":[{\"name\":\"heck\",\"req\":\"^0.5\"},{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}", "enumflags2_0.7.12": "{\"dependencies\":[{\"name\":\"enumflags2_derive\",\"req\":\"=0.7.12\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.0\"}],\"features\":{\"std\":[]}}", "enumflags2_derive_0.7.12": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1.0\"},{\"name\":\"quote\",\"req\":\"^1.0\"},{\"default_features\":false,\"features\":[\"parsing\",\"printing\",\"derive\",\"proc-macro\"],\"name\":\"syn\",\"req\":\"^2.0\"}],\"features\":{}}", - "env-flags_0.1.1": "{\"dependencies\":[],\"features\":{}}", "env_filter_1.0.0": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"log\",\"req\":\"^0.4.8\"},{\"default_features\":false,\"features\":[\"std\",\"perf\"],\"name\":\"regex\",\"optional\":true,\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^0.6\"}],\"features\":{\"default\":[\"regex\"],\"regex\":[\"dep:regex\"]}}", "env_filter_1.0.1": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"log\",\"req\":\"^0.4.29\"},{\"default_features\":false,\"features\":[\"std\",\"perf\"],\"name\":\"regex\",\"optional\":true,\"req\":\"^1.12.3\"},{\"kind\":\"dev\",\"name\":\"snapbox\",\"req\":\"^1.0\"}],\"features\":{\"default\":[\"regex\"],\"regex\":[\"dep:regex\"]}}", "env_home_0.1.0": "{\"dependencies\":[],\"features\":{}}", diff --git a/codex-cli/scripts/build_npm_package.py b/codex-cli/scripts/build_npm_package.py index eda6c26152..c38fed68d9 100755 --- a/codex-cli/scripts/build_npm_package.py +++ b/codex-cli/scripts/build_npm_package.py @@ -69,8 +69,8 @@ PACKAGE_EXPANSIONS: dict[str, list[str]] = { PACKAGE_NATIVE_COMPONENTS: dict[str, list[str]] = { "codex": [], - "codex-linux-x64": ["codex", "rg"], - "codex-linux-arm64": ["codex", "rg"], + "codex-linux-x64": ["bwrap", "codex", "rg"], + "codex-linux-arm64": ["bwrap", "codex", "rg"], "codex-darwin-x64": ["codex", "rg"], "codex-darwin-arm64": ["codex", "rg"], "codex-win32-x64": ["codex", "rg", "codex-windows-sandbox-setup", "codex-command-runner"], @@ -87,6 +87,7 @@ PACKAGE_TARGET_FILTERS: dict[str, str] = { PACKAGE_CHOICES = tuple(PACKAGE_NATIVE_COMPONENTS) COMPONENT_DEST_DIR: dict[str, str] = { + "bwrap": "codex-resources", "codex": "codex", "codex-responses-api-proxy": "codex-responses-api-proxy", "codex-windows-sandbox-setup": "codex", @@ -137,6 +138,16 @@ def parse_args() -> argparse.Namespace: type=Path, help="Directory containing pre-installed native binaries to bundle (vendor root).", ) + parser.add_argument( + "--allow-missing-native-component", + dest="allow_missing_native_components", + action="append", + default=[], + help=( + "Native component that may be absent from --vendor-src. Intended for CI " + "compatibility with older artifact workflows; releases should not use this." + ), + ) return parser.parse_args() @@ -177,6 +188,7 @@ def main() -> int: staging_dir, native_components, target_filter={target_filter} if target_filter else None, + allow_missing_components=set(args.allow_missing_native_components), ) if release_version: @@ -365,12 +377,14 @@ def copy_native_binaries( staging_dir: Path, components: list[str], target_filter: set[str] | None = None, + allow_missing_components: set[str] | None = None, ) -> None: vendor_src = vendor_src.resolve() if not vendor_src.exists(): raise RuntimeError(f"Vendor source directory not found: {vendor_src}") components_set = {component for component in components if component in COMPONENT_DEST_DIR} + allow_missing_components = allow_missing_components or set() if not components_set: return @@ -399,6 +413,8 @@ def copy_native_binaries( src_component_dir = target_dir / dest_dir_name if not src_component_dir.exists(): + if component in allow_missing_components: + continue raise RuntimeError( f"Missing native component '{component}' in vendor source: {src_component_dir}" ) diff --git a/codex-cli/scripts/install_native_deps.py b/codex-cli/scripts/install_native_deps.py index 58fbd370fc..79f596fbaf 100755 --- a/codex-cli/scripts/install_native_deps.py +++ b/codex-cli/scripts/install_native_deps.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Install Codex native binaries (Rust CLI plus ripgrep helpers).""" +"""Install Codex native binaries (Rust CLI, bwrap, and ripgrep helpers).""" import argparse from contextlib import contextmanager @@ -42,8 +42,15 @@ class BinaryComponent: WINDOWS_TARGETS = tuple(target for target in BINARY_TARGETS if "windows" in target) +LINUX_TARGETS = tuple(target for target in BINARY_TARGETS if "linux" in target) BINARY_COMPONENTS = { + "bwrap": BinaryComponent( + artifact_prefix="bwrap", + dest_dir="codex-resources", + binary_basename="bwrap", + targets=LINUX_TARGETS, + ), "codex": BinaryComponent( artifact_prefix="codex", dest_dir="codex", @@ -135,7 +142,7 @@ def parse_args() -> argparse.Namespace: choices=tuple(list(BINARY_COMPONENTS) + ["rg"]), help=( "Limit installation to the specified components." - " May be repeated. Defaults to codex, codex-windows-sandbox-setup," + " May be repeated. Defaults to bwrap, codex, codex-windows-sandbox-setup," " codex-command-runner, and rg." ), ) @@ -159,6 +166,7 @@ def main() -> int: vendor_dir.mkdir(parents=True, exist_ok=True) components = args.components or [ + "bwrap", "codex", "codex-windows-sandbox-setup", "codex-command-runner", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 2c18d3e573..b902ae9e9b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -757,6 +757,7 @@ checksum = "96571e6996817bf3d58f6b569e4b9fd2e9d2fcf9f7424eed07b2ce9bb87535e5" dependencies = [ "aws-credential-types", "aws-runtime", + "aws-sdk-signin", "aws-sdk-sso", "aws-sdk-ssooidc", "aws-sdk-sts", @@ -767,15 +768,20 @@ dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", "aws-types", + "base64-simd", "bytes", "fastrand", "hex", "http 1.4.0", + "p256", + "rand 0.8.5", "ring", + "sha2", "time", "tokio", "tracing", "url", + "uuid", "zeroize", ] @@ -838,6 +844,28 @@ dependencies = [ "uuid", ] +[[package]] +name = "aws-sdk-signin" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c084bd63941916e1348cb8d9e05ac2e49bdd40a380e9167702683184c6c6be53" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "regex-lite", + "tracing", +] + [[package]] name = "aws-sdk-sso" version = "1.91.0" @@ -1866,14 +1894,17 @@ dependencies = [ "codex-config", "codex-core", "codex-core-plugins", - "codex-device-key", "codex-exec-server", + "codex-extension-api", "codex-external-agent-migration", "codex-external-agent-sessions", "codex-features", "codex-feedback", "codex-file-search", + "codex-file-watcher", + "codex-git-attribution", "codex-git-utils", + "codex-guardian", "codex-hooks", "codex-login", "codex-mcp", @@ -1939,11 +1970,14 @@ dependencies = [ "codex-exec-server", "codex-feedback", "codex-protocol", + "codex-uds", + "codex-utils-absolute-path", "codex-utils-rustls-provider", "futures", "pretty_assertions", "serde", "serde_json", + "tempfile", "tokio", "tokio-tungstenite", "toml 0.9.11+spec-1.1.0", @@ -1951,6 +1985,27 @@ dependencies = [ "url", ] +[[package]] +name = "codex-app-server-daemon" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-app-server-protocol", + "codex-app-server-transport", + "codex-uds", + "codex-utils-home-dir", + "futures", + "libc", + "pretty_assertions", + "reqwest", + "serde", + "serde_json", + "sha2", + "tempfile", + "tokio", + "tokio-tungstenite", +] + [[package]] name = "codex-app-server-protocol" version = "0.0.0" @@ -2125,6 +2180,15 @@ dependencies = [ "serde_with", ] +[[package]] +name = "codex-bwrap" +version = "0.0.0" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "codex-chatgpt" version = "0.0.0" @@ -2158,6 +2222,7 @@ dependencies = [ "clap", "clap_complete", "codex-app-server", + "codex-app-server-daemon", "codex-app-server-protocol", "codex-app-server-test-client", "codex-arg0", @@ -2220,6 +2285,7 @@ dependencies = [ "opentelemetry_sdk", "pretty_assertions", "rand 0.9.3", + "rcgen", "reqwest", "rustls", "rustls-native-certs", @@ -2392,7 +2458,11 @@ dependencies = [ "codex-app-server-protocol", "pretty_assertions", "serde", + "serde_json", + "sha1", + "tempfile", "tokio", + "tracing", "urlencoding", ] @@ -2422,6 +2492,7 @@ dependencies = [ "codex-core-skills", "codex-exec-server", "codex-execpolicy", + "codex-extension-api", "codex-features", "codex-feedback", "codex-git-utils", @@ -2447,6 +2518,7 @@ dependencies = [ "codex-terminal-detection", "codex-test-binary-support", "codex-thread-store", + "codex-tool-api", "codex-tools", "codex-utils-absolute-path", "codex-utils-cache", @@ -2457,7 +2529,6 @@ dependencies = [ "codex-utils-path", "codex-utils-plugins", "codex-utils-pty", - "codex-utils-readiness", "codex-utils-stream-parser", "codex-utils-string", "codex-utils-template", @@ -2467,7 +2538,6 @@ dependencies = [ "ctor 0.6.3", "dirs", "dunce", - "env-flags", "eventsource-stream", "futures", "http 1.4.0", @@ -2477,7 +2547,6 @@ dependencies = [ "insta", "libc", "maplit", - "notify", "once_cell", "openssl-sys", "opentelemetry", @@ -2526,6 +2595,7 @@ dependencies = [ "codex-config", "codex-core", "codex-exec-server", + "codex-extension-api", "codex-features", "codex-login", "codex-model-provider-info", @@ -2546,6 +2616,7 @@ dependencies = [ "codex-core-skills", "codex-exec-server", "codex-git-utils", + "codex-hooks", "codex-login", "codex-model-provider", "codex-otel", @@ -2614,22 +2685,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "codex-device-key" -version = "0.0.0" -dependencies = [ - "async-trait", - "base64 0.22.1", - "p256", - "pretty_assertions", - "rand 0.9.3", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "url", -] - [[package]] name = "codex-exec" version = "0.0.0" @@ -2682,6 +2737,7 @@ dependencies = [ "anyhow", "arc-swap", "async-trait", + "axum", "base64 0.22.1", "bytes", "codex-app-server-protocol", @@ -2692,9 +2748,11 @@ dependencies = [ "codex-test-binary-support", "codex-utils-absolute-path", "codex-utils-pty", + "codex-utils-rustls-provider", "ctor 0.6.3", "futures", "pretty_assertions", + "prost 0.14.3", "reqwest", "serde", "serde_json", @@ -2705,8 +2763,10 @@ dependencies = [ "tokio", "tokio-tungstenite", "tokio-util", + "toml 0.9.11+spec-1.1.0", "tracing", "uuid", + "wiremock", ] [[package]] @@ -2755,6 +2815,14 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "codex-extension-api" +version = "0.0.0" +dependencies = [ + "codex-protocol", + "codex-tool-api", +] + [[package]] name = "codex-external-agent-migration" version = "0.0.0" @@ -2833,6 +2901,28 @@ dependencies = [ "serde", ] +[[package]] +name = "codex-file-watcher" +version = "0.0.0" +dependencies = [ + "notify", + "pretty_assertions", + "tempfile", + "tokio", + "tracing", +] + +[[package]] +name = "codex-git-attribution" +version = "0.0.0" +dependencies = [ + "codex-core", + "codex-extension-api", + "codex-features", + "codex-protocol", + "pretty_assertions", +] + [[package]] name = "codex-git-utils" version = "0.0.0" @@ -2857,6 +2947,15 @@ dependencies = [ "walkdir", ] +[[package]] +name = "codex-guardian" +version = "0.0.0" +dependencies = [ + "codex-core", + "codex-extension-api", + "codex-protocol", +] + [[package]] name = "codex-hooks" version = "0.0.0" @@ -2867,6 +2966,7 @@ dependencies = [ "codex-plugin", "codex-protocol", "codex-utils-absolute-path", + "codex-utils-output-truncation", "futures", "pretty_assertions", "regex", @@ -2875,6 +2975,8 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "tracing", + "uuid", ] [[package]] @@ -2898,7 +3000,6 @@ dependencies = [ name = "codex-linux-sandbox" version = "0.0.0" dependencies = [ - "cc", "clap", "codex-core", "codex-process-hardening", @@ -2908,11 +3009,11 @@ dependencies = [ "globset", "landlock", "libc", - "pkg-config", "pretty_assertions", "seccompiler", "serde", "serde_json", + "sha2", "tempfile", "tokio", "url", @@ -3015,6 +3116,7 @@ dependencies = [ "codex-config", "codex-core", "codex-exec-server", + "codex-extension-api", "codex-login", "codex-protocol", "codex-shell-command", @@ -3037,6 +3139,23 @@ dependencies = [ "wiremock", ] +[[package]] +name = "codex-memories-mcp" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-utils-absolute-path", + "codex-utils-output-truncation", + "pretty_assertions", + "rmcp", + "schemars 0.8.22", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", +] + [[package]] name = "codex-memories-read" version = "0.0.0" @@ -3086,6 +3205,19 @@ dependencies = [ "wiremock", ] +[[package]] +name = "codex-message-history" +version = "0.0.0" +dependencies = [ + "codex-config", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "codex-model-provider" version = "0.0.0" @@ -3261,7 +3393,6 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-image", "codex-utils-string", - "codex-utils-template", "encoding_rs", "globset", "http 1.4.0", @@ -3395,6 +3526,7 @@ dependencies = [ "anyhow", "codex-code-mode", "codex-protocol", + "http 1.4.0", "pretty_assertions", "serde", "serde_json", @@ -3567,21 +3699,26 @@ dependencies = [ "codex-protocol", "codex-rollout", "codex-state", + "codex-utils-path", "pretty_assertions", - "prost 0.14.3", "serde", "serde_json", "tempfile", "thiserror 2.0.18", "tokio", - "tokio-stream", - "tonic", - "tonic-prost", - "tonic-prost-build", "tracing", "uuid", ] +[[package]] +name = "codex-tool-api" +version = "0.0.0" +dependencies = [ + "pretty_assertions", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "codex-tools" version = "0.0.0" @@ -3628,6 +3765,7 @@ dependencies = [ "codex-install-context", "codex-login", "codex-mcp", + "codex-message-history", "codex-model-provider", "codex-model-provider-info", "codex-models-manager", @@ -3654,6 +3792,7 @@ dependencies = [ "codex-utils-string", "codex-windows-sandbox", "color-eyre", + "core_test_support", "cpal", "crossterm", "derive_more 2.1.1", @@ -3677,6 +3816,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "sha2", "shlex", "strum 0.27.2", "strum_macros 0.28.0", @@ -3703,6 +3843,7 @@ dependencies = [ "which 8.0.0", "windows-sys 0.52.0", "winsplit", + "wiremock", ] [[package]] @@ -4197,7 +4338,9 @@ dependencies = [ "codex-config", "codex-core", "codex-exec-server", + "codex-extension-api", "codex-features", + "codex-hooks", "codex-login", "codex-model-provider-info", "codex-models-manager", @@ -4214,6 +4357,7 @@ dependencies = [ "reqwest", "serde_json", "shlex", + "similar", "tempfile", "tokio", "tokio-tungstenite", @@ -5248,12 +5392,6 @@ dependencies = [ "syn 2.0.114", ] -[[package]] -name = "env-flags" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbfd0e7fc632dec5e6c9396a27bc9f9975b4e039720e1fd3e34021d3ce28c415" - [[package]] name = "env_filter" version = "1.0.0" @@ -8853,7 +8991,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -9321,7 +9459,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -13965,7 +14103,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 2efba8b636..612ca8a7ea 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -5,10 +5,12 @@ members = [ "agent-graph-store", "agent-identity", "backend-client", + "bwrap", "ansi-escape", "async-utils", "app-server", "app-server-transport", + "app-server-daemon", "app-server-client", "app-server-protocol", "app-server-test-client", @@ -28,7 +30,6 @@ members = [ "collaboration-mode-templates", "connectors", "config", - "device-key", "shell-command", "shell-escalation", "skills", @@ -43,15 +44,20 @@ members = [ "exec-server", "execpolicy", "execpolicy-legacy", + "ext/extension-api", + "ext/guardian", + "ext/git-attribution", "external-agent-migration", "external-agent-sessions", "keyring-store", "file-search", + "file-watcher", "linux-sandbox", "lmstudio", "login", "codex-mcp", "mcp-server", + "memories/mcp", "memories/read", "memories/write", "model-provider-info", @@ -102,6 +108,7 @@ members = [ "test-binary-support", "thread-manager-sample", "thread-store", + "tool-api", "uds", "codex-experimental-api-macros", "plugin", @@ -129,6 +136,7 @@ codex-api = { path = "codex-api" } codex-aws-auth = { path = "aws-auth" } codex-app-server = { path = "app-server" } codex-app-server-transport = { path = "app-server-transport" } +codex-app-server-daemon = { path = "app-server-daemon" } codex-app-server-client = { path = "app-server-client" } codex-app-server-protocol = { path = "app-server-protocol" } codex-app-server-test-client = { path = "app-server-test-client" } @@ -150,11 +158,13 @@ codex-core = { path = "core" } codex-core-api = { path = "core-api" } codex-core-plugins = { path = "core-plugins" } codex-core-skills = { path = "core-skills" } -codex-device-key = { path = "device-key" } codex-exec = { path = "exec" } codex-file-system = { path = "file-system" } codex-exec-server = { path = "exec-server" } codex-execpolicy = { path = "execpolicy" } +codex-extension-api = { path = "ext/extension-api" } +codex-guardian = { path = "ext/guardian" } +codex-git-attribution = { path = "ext/git-attribution" } codex-external-agent-migration = { path = "external-agent-migration" } codex-external-agent-sessions = { path = "external-agent-sessions" } codex-experimental-api-macros = { path = "codex-experimental-api-macros" } @@ -162,12 +172,14 @@ codex-features = { path = "features" } codex-feedback = { path = "feedback" } codex-install-context = { path = "install-context" } codex-file-search = { path = "file-search" } +codex-file-watcher = { path = "file-watcher" } codex-git-utils = { path = "git-utils" } codex-hooks = { path = "hooks" } codex-keyring-store = { path = "keyring-store" } codex-linux-sandbox = { path = "linux-sandbox" } codex-lmstudio = { path = "lmstudio" } codex-login = { path = "login" } +codex-message-history = { path = "message-history" } codex-memories-read = { path = "memories/read" } codex-memories-write = { path = "memories/write" } codex-mcp = { path = "codex-mcp" } @@ -197,6 +209,7 @@ codex-stdio-to-uds = { path = "stdio-to-uds" } codex-terminal-detection = { path = "terminal-detection" } codex-test-binary-support = { path = "test-binary-support" } codex-thread-store = { path = "thread-store" } +codex-tool-api = { path = "tool-api" } codex-tools = { path = "tools" } codex-tui = { path = "tui" } codex-uds = { path = "uds" } @@ -215,7 +228,6 @@ codex-utils-output-truncation = { path = "utils/output-truncation" } codex-utils-path = { path = "utils/path-utils" } codex-utils-plugins = { path = "utils/plugins" } codex-utils-pty = { path = "utils/pty" } -codex-utils-readiness = { path = "utils/readiness" } codex-utils-rustls-provider = { path = "utils/rustls-provider" } codex-utils-sandbox-summary = { path = "utils/sandbox-summary" } codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" } @@ -268,7 +280,6 @@ dotenvy = "0.15.7" dunce = "1.0.4" ed25519-dalek = { version = "2.2.0", features = ["pkcs8"] } encoding_rs = "0.8.35" -env-flags = "0.1.1" env_logger = "0.11.9" eventsource-stream = "0.2.3" flate2 = "1.1.8" @@ -313,7 +324,6 @@ os_info = "3.12.0" owo-colors = "4.3.0" path-absolutize = "3.1.1" pathdiff = "0.2" -p256 = "0.13.2" portable-pty = "0.9.0" predicates = "3" pretty_assertions = "1.4.1" @@ -322,6 +332,10 @@ quick-xml = "0.38.4" rand = "0.9" ratatui = "0.29.0" ratatui-macros = "0.6.0" +rcgen = { version = "0.14.7", default-features = false, features = [ + "aws_lc_rs", + "pem", +] } regex = "1.12.3" regex-lite = "0.1.8" reqwest = { version = "0.12", features = ["cookies"] } @@ -459,21 +473,19 @@ ignored = [ "codex-agent-graph-store", "icu_provider", "openssl-sys", - "codex-utils-readiness", - "codex-utils-template", "codex-v8-poc", ] [profile.dev] # Keep line tables/backtraces while avoiding expensive full variable debug info # across local dev builds. -debug = 1 +debug = "limited" [profile.dev-small] inherits = "dev" opt-level = 0 -debug = 0 -strip = true +debug = "none" +strip = "symbols" [profile.release] lto = "fat" @@ -485,8 +497,15 @@ strip = "symbols" # See https://github.com/openai/codex/issues/1411 for details. codegen-units = 1 +[profile.profiling] +inherits = "release" +debug = "full" +lto = false +strip = false + [profile.ci-test] -debug = 1 # Reduce debug symbol size +# Reduce binary size to reduce disk pressure. +debug = "limited" inherits = "test" opt-level = 0 diff --git a/codex-rs/README.md b/codex-rs/README.md index 2cc3a6b8f1..d219061a35 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -46,7 +46,7 @@ Use `codex mcp` to add/list/get/remove MCP server launchers defined in `config.t ### Notifications -The legacy `notify` setting is deprecated and will be removed in a future release. Existing configurations still work, but new automation should use lifecycle hooks instead. The [notify documentation](../docs/config.md#notify) explains the remaining compatibility behavior. When Codex detects that it is running under WSL 2 inside Windows Terminal (`WT_SESSION` is set), the TUI automatically falls back to native Windows toast notifications so approval prompts and completed turns surface even though Windows Terminal does not implement OSC 9. +You can enable notifications by configuring a script that is run whenever the agent finishes a turn. The [notify documentation](../docs/config.md#notify) includes a detailed example that explains how to get desktop notifications via [terminal-notifier](https://github.com/julienXX/terminal-notifier) on macOS. When Codex detects that it is running under WSL 2 inside Windows Terminal (`WT_SESSION` is set), the TUI automatically falls back to native Windows toast notifications so approval prompts and completed turns surface even though Windows Terminal does not implement OSC 9. ### `codex exec` to run Codex programmatically/non-interactively diff --git a/codex-rs/agent-graph-store/Cargo.toml b/codex-rs/agent-graph-store/Cargo.toml index e221ef61b2..9ecd827194 100644 --- a/codex-rs/agent-graph-store/Cargo.toml +++ b/codex-rs/agent-graph-store/Cargo.toml @@ -7,6 +7,7 @@ version.workspace = true [lib] name = "codex_agent_graph_store" path = "src/lib.rs" +doctest = false [lints] workspace = true diff --git a/codex-rs/analytics/src/accepted_lines.rs b/codex-rs/analytics/src/accepted_lines.rs new file mode 100644 index 0000000000..fd94bb2827 --- /dev/null +++ b/codex-rs/analytics/src/accepted_lines.rs @@ -0,0 +1,256 @@ +use crate::events::CodexAcceptedLineFingerprintsEventParams; +use crate::events::CodexAcceptedLineFingerprintsEventRequest; +use crate::events::TrackEventRequest; +use crate::facts::AcceptedLineFingerprint; +use codex_git_utils::canonicalize_git_remote_url; +use codex_git_utils::get_git_remote_urls_assume_git_repo; +use sha1::Digest; +use std::path::Path; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AcceptedLineFingerprintSummary { + pub accepted_added_lines: u64, + pub accepted_deleted_lines: u64, + pub line_fingerprints: Vec, +} + +pub(crate) struct AcceptedLineFingerprintEventInput { + pub(crate) event_type: &'static str, + pub(crate) turn_id: String, + pub(crate) thread_id: String, + pub(crate) product_surface: Option, + pub(crate) model_slug: Option, + pub(crate) completed_at: u64, + pub(crate) repo_hash: Option, + pub(crate) accepted_added_lines: u64, + pub(crate) accepted_deleted_lines: u64, + pub(crate) line_fingerprints: Vec, +} + +pub fn accepted_line_fingerprints_from_unified_diff( + unified_diff: &str, +) -> AcceptedLineFingerprintSummary { + let mut current_path: Option = None; + let mut in_hunk = false; + let mut accepted_added_lines = 0; + let mut accepted_deleted_lines = 0; + let mut line_fingerprints = Vec::new(); + + for line in unified_diff.lines() { + if line.starts_with("diff --git ") { + current_path = None; + in_hunk = false; + continue; + } + + if line.starts_with("@@ ") { + in_hunk = true; + continue; + } + + if !in_hunk && let Some(path) = line.strip_prefix("+++ ") { + current_path = normalize_diff_path(path); + continue; + } + + if !in_hunk && line.starts_with("--- ") { + continue; + } + + if let Some(added_line) = line.strip_prefix('+') { + accepted_added_lines += 1; + if let Some(path) = current_path.as_deref() + && let Some(normalized_line) = normalize_effective_line(added_line) + { + line_fingerprints.push(AcceptedLineFingerprint { + path_hash: fingerprint_hash("path", path), + line_hash: fingerprint_hash("line", &normalized_line), + }); + } + continue; + } + + if line.starts_with('-') { + accepted_deleted_lines += 1; + } + } + + AcceptedLineFingerprintSummary { + accepted_added_lines, + accepted_deleted_lines, + line_fingerprints, + } +} + +pub fn fingerprint_hash(domain: &str, value: &str) -> String { + let mut hasher = sha1::Sha1::new(); + hasher.update(b"file-line-v1\0"); + hasher.update(domain.as_bytes()); + hasher.update(b"\0"); + hasher.update(value.as_bytes()); + format!("{:x}", hasher.finalize()) +} + +pub(crate) fn accepted_line_fingerprint_event_requests( + input: AcceptedLineFingerprintEventInput, +) -> Vec { + let AcceptedLineFingerprintEventInput { + event_type, + turn_id, + thread_id, + product_surface, + model_slug, + completed_at, + repo_hash, + accepted_added_lines, + accepted_deleted_lines, + line_fingerprints: _line_fingerprints, + } = input; + + vec![TrackEventRequest::AcceptedLineFingerprints(Box::new( + CodexAcceptedLineFingerprintsEventRequest { + event_type: "codex_accepted_line_fingerprints", + event_params: CodexAcceptedLineFingerprintsEventParams { + event_type, + turn_id, + thread_id, + product_surface, + model_slug, + completed_at, + repo_hash, + accepted_added_lines, + accepted_deleted_lines, + // Keep computing local fingerprints for parsing tests and future attribution, + // but do not upload path/line hashes in the analytics event payload. + line_fingerprints: Vec::new(), + }, + }, + ))] +} + +pub async fn accepted_line_repo_hash_for_cwd(cwd: &Path) -> Option { + let remotes = get_git_remote_urls_assume_git_repo(cwd).await?; + remotes + .get("origin") + .or_else(|| remotes.values().next()) + .map(|remote_url| { + let canonical_remote_url = + canonicalize_git_remote_url(remote_url).unwrap_or_else(|| remote_url.to_string()); + fingerprint_hash("repo", &canonical_remote_url) + }) +} + +fn normalize_diff_path(path: &str) -> Option { + let path = path.trim(); + if path == "/dev/null" { + return None; + } + + Some( + path.strip_prefix("b/") + .or_else(|| path.strip_prefix("a/")) + .unwrap_or(path) + .to_string(), + ) +} + +fn normalize_effective_line(line: &str) -> Option { + let normalized = line.split_whitespace().collect::>().join(" "); + if normalized.len() <= 3 { + return None; + } + if !normalized + .chars() + .any(|ch| ch.is_alphanumeric() || ch == '_') + { + return None; + } + Some(normalized) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_counts_and_effective_added_fingerprints() { + let diff = "\ +diff --git a/src/lib.rs b/src/lib.rs +index 1111111..2222222 +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -1,3 +1,5 @@ +-old line ++fn useful() { ++} ++ return user.id; + context +"; + + let summary = accepted_line_fingerprints_from_unified_diff(diff); + + assert_eq!( + summary, + AcceptedLineFingerprintSummary { + accepted_added_lines: 3, + accepted_deleted_lines: 1, + line_fingerprints: vec![ + AcceptedLineFingerprint { + path_hash: fingerprint_hash("path", "src/lib.rs"), + line_hash: fingerprint_hash("line", "fn useful() {"), + }, + AcceptedLineFingerprint { + path_hash: fingerprint_hash("path", "src/lib.rs"), + line_hash: fingerprint_hash("line", "return user.id;"), + }, + ], + } + ); + } + + #[test] + fn skips_added_file_metadata_headers() { + let diff = "\ +diff --git a/new.py b/new.py +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/new.py +@@ -0,0 +1 @@ ++print('hello') +"; + + let summary = accepted_line_fingerprints_from_unified_diff(diff); + + assert_eq!(summary.accepted_added_lines, 1); + assert_eq!(summary.accepted_deleted_lines, 0); + assert_eq!(summary.line_fingerprints.len(), 1); + } + + #[test] + fn parses_hunk_lines_that_look_like_file_headers() { + let diff = "\ +diff --git a/src/lib.rs b/src/lib.rs +index 1111111..2222222 +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -1,2 +1,2 @@ +--- old value ++++ new value +"; + + let summary = accepted_line_fingerprints_from_unified_diff(diff); + + assert_eq!( + summary, + AcceptedLineFingerprintSummary { + accepted_added_lines: 1, + accepted_deleted_lines: 1, + line_fingerprints: vec![AcceptedLineFingerprint { + path_hash: fingerprint_hash("path", "src/lib.rs"), + line_hash: fingerprint_hash("line", "++ new value"), + }], + } + ); + } +} diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 52ece67a13..5b4a72229b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -1,22 +1,36 @@ use crate::client::AnalyticsEventsQueue; use crate::events::AppServerRpcTransport; +use crate::events::CodexAcceptedLineFingerprintsEventParams; +use crate::events::CodexAcceptedLineFingerprintsEventRequest; use crate::events::CodexAppMentionedEventRequest; use crate::events::CodexAppServerClientMetadata; use crate::events::CodexAppUsedEventRequest; +use crate::events::CodexCommandExecutionEventParams; +use crate::events::CodexCommandExecutionEventRequest; use crate::events::CodexCompactionEventRequest; use crate::events::CodexHookRunEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; +use crate::events::CodexReviewEventParams; +use crate::events::CodexReviewEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolItemEventBase; use crate::events::CodexTurnEventRequest; +use crate::events::FinalApprovalOutcome; use crate::events::GuardianApprovalRequestSource; use crate::events::GuardianReviewDecision; use crate::events::GuardianReviewEventParams; use crate::events::GuardianReviewFailureReason; use crate::events::GuardianReviewTerminalStatus; use crate::events::GuardianReviewedAction; +use crate::events::ReviewResolution; +use crate::events::ReviewStatus; +use crate::events::ReviewSubjectKind; +use crate::events::ReviewTrigger; +use crate::events::Reviewer; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolItemTerminalStatus; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; use crate::events::codex_hook_run_metadata; @@ -61,22 +75,47 @@ use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponsePayload; use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandAction; +use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::CommandExecutionRequestApprovalParams; +use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::DynamicToolCallStatus; +use codex_app_server_protocol::GuardianApprovalReview; +use codex_app_server_protocol::GuardianApprovalReviewAction; +use codex_app_server_protocol::GuardianApprovalReviewStatus; +use codex_app_server_protocol::GuardianCommandSource as AppServerGuardianCommandSource; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::ItemCompletedNotification; +use codex_app_server_protocol::ItemGuardianApprovalReviewCompletedNotification; +use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::McpToolCallStatus; use codex_app_server_protocol::NonSteerableTurnKind; +use codex_app_server_protocol::PatchApplyStatus; +use codex_app_server_protocol::PermissionsRequestApprovalParams; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::RequestPermissionProfile; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; use codex_app_server_protocol::SessionSource as AppServerSessionSource; use codex_app_server_protocol::Thread; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadArchiveResponse; +use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadSource as AppServerThreadSource; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus as AppServerThreadStatus; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; +use codex_app_server_protocol::TurnDiffUpdatedNotification; use codex_app_server_protocol::TurnError as AppServerTurnError; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartedNotification; @@ -93,6 +132,7 @@ use codex_plugin::PluginTelemetryMetadata; use codex_protocol::approvals::NetworkApprovalProtocol; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::ModeKind; +use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; use codex_protocol::models::PermissionProfile as CorePermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::HookEventName; @@ -101,7 +141,11 @@ use codex_protocol::protocol::HookSource; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsage; +use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; +use codex_protocol::request_permissions::RequestPermissionsResponse as CoreRequestPermissionsResponse; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; @@ -112,17 +156,15 @@ use std::sync::Arc; use std::sync::Mutex; use tokio::sync::mpsc; -fn sample_thread(thread_id: &str, ephemeral: bool) -> Thread { - sample_thread_with_source(thread_id, ephemeral, AppServerSessionSource::Exec) -} - -fn sample_thread_with_source( +fn sample_thread_with_metadata( thread_id: &str, ephemeral: bool, source: AppServerSessionSource, + thread_source: Option, ) -> Thread { Thread { id: thread_id.to_string(), + session_id: format!("session-{thread_id}"), forked_from_id: None, preview: "first prompt".to_string(), ephemeral, @@ -134,6 +176,7 @@ fn sample_thread_with_source( cwd: test_path_buf("/tmp").abs(), cli_version: "0.0.0".to_string(), source, + thread_source, agent_nickname: None, agent_role: None, git_info: None, @@ -148,7 +191,12 @@ fn sample_thread_start_response( model: &str, ) -> ClientResponsePayload { ClientResponsePayload::ThreadStart(ThreadStartResponse { - thread: sample_thread(thread_id, ephemeral), + thread: sample_thread_with_metadata( + thread_id, + ephemeral, + AppServerSessionSource::Exec, + Some(AppServerThreadSource::User), + ), model: model.to_string(), model_provider: "openai".to_string(), service_tier: None, @@ -192,6 +240,7 @@ fn sample_thread_resume_response( ephemeral, model, AppServerSessionSource::Exec, + Some(AppServerThreadSource::User), ) } @@ -200,9 +249,10 @@ fn sample_thread_resume_response_with_source( ephemeral: bool, model: &str, source: AppServerSessionSource, + thread_source: Option, ) -> ClientResponsePayload { ClientResponsePayload::ThreadResume(ThreadResumeResponse { - thread: sample_thread_with_source(thread_id, ephemeral, source), + thread: sample_thread_with_metadata(thread_id, ephemeral, source, thread_source), model: model.to_string(), model_provider: "openai".to_string(), service_tier: None, @@ -240,6 +290,7 @@ fn sample_turn_start_response(turn_id: &str) -> ClientResponsePayload { ClientResponsePayload::TurnStart(codex_app_server_protocol::TurnStartResponse { turn: Turn { id: turn_id.to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![], status: AppServerTurnStatus::InProgress, error: None, @@ -255,6 +306,7 @@ fn sample_turn_started_notification(thread_id: &str, turn_id: &str) -> ServerNot thread_id: thread_id.to_string(), turn: Turn { id: turn_id.to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![], status: AppServerTurnStatus::InProgress, error: None, @@ -289,6 +341,7 @@ fn sample_turn_completed_notification( thread_id: thread_id.to_string(), turn: Turn { id: turn_id.to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![], status, error: codex_error_info.map(|codex_error_info| AppServerTurnError { @@ -581,6 +634,244 @@ async fn ingest_turn_prerequisites( } } +async fn ingest_review_prerequisites( + reducer: &mut AnalyticsReducer, + events: &mut Vec, +) { + reducer + .ingest(sample_initialize_fact(/*connection_id*/ 7), events) + .await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(1), + response: Box::new(sample_thread_start_response( + "thread-1", /*ephemeral*/ false, "gpt-5", + )), + }, + events, + ) + .await; + events.clear(); +} + +async fn ingest_completed_command_execution_item( + reducer: &mut AnalyticsReducer, + events: &mut Vec, + thread_id: &str, + item_id: &str, +) { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + thread_id, "turn-1", + ))), + events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted( + ItemStartedNotification { + thread_id: thread_id.to_string(), + turn_id: "turn-1".to_string(), + started_at_ms: 1_000, + item: sample_command_execution_item_with_id( + item_id, + CommandExecutionStatus::InProgress, + /*exit_code*/ None, + /*duration_ms*/ None, + ), + }, + ))), + events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted( + ItemCompletedNotification { + thread_id: thread_id.to_string(), + turn_id: "turn-1".to_string(), + completed_at_ms: 1_042, + item: sample_command_execution_item_with_id( + item_id, + CommandExecutionStatus::Completed, + Some(0), + Some(42), + ), + }, + ))), + events, + ) + .await; +} + +fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact { + AnalyticsFact::Initialize { + connection_id, + params: InitializeParams { + client_info: ClientInfo { + name: "codex-tui".to_string(), + title: None, + version: "1.0.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: false, + request_attestation: 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, + } +} + +fn sample_command_execution_item( + status: CommandExecutionStatus, + exit_code: Option, + duration_ms: Option, +) -> ThreadItem { + sample_command_execution_item_with_id("item-1", status, exit_code, duration_ms) +} + +fn sample_command_execution_item_with_id( + id: &str, + status: CommandExecutionStatus, + exit_code: Option, + duration_ms: Option, +) -> ThreadItem { + ThreadItem::CommandExecution { + id: id.to_string(), + command: "echo hi".to_string(), + cwd: test_path_buf("/tmp").abs(), + process_id: Some("pid-1".to_string()), + source: CommandExecutionSource::Agent, + status, + command_actions: Vec::new(), + aggregated_output: None, + exit_code, + duration_ms, + } +} + +fn sample_command_execution_item_with_actions( + status: CommandExecutionStatus, + exit_code: Option, + duration_ms: Option, + command_actions: Vec, +) -> ThreadItem { + let mut item = sample_command_execution_item(status, exit_code, duration_ms); + let ThreadItem::CommandExecution { + command_actions: item_command_actions, + .. + } = &mut item + else { + unreachable!("sample command execution item should be CommandExecution"); + }; + *item_command_actions = command_actions; + item +} + +fn sample_command_approval_request(request_id: i64, approval_id: Option<&str>) -> ServerRequest { + ServerRequest::CommandExecutionRequestApproval { + request_id: RequestId::Integer(request_id), + params: CommandExecutionRequestApprovalParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + started_at_ms: 1_000, + approval_id: approval_id.map(str::to_string), + reason: None, + network_approval_context: None, + command: Some("echo hi".to_string()), + cwd: None, + command_actions: None, + additional_permissions: None, + proposed_execpolicy_amendment: None, + proposed_network_policy_amendments: None, + available_decisions: None, + }, + } +} + +fn sample_command_approval_response( + request_id: i64, + decision: CommandExecutionApprovalDecision, +) -> ServerResponse { + ServerResponse::CommandExecutionRequestApproval { + request_id: RequestId::Integer(request_id), + response: CommandExecutionRequestApprovalResponse { decision }, + } +} + +fn sample_permissions_approval_request(request_id: i64) -> ServerRequest { + ServerRequest::PermissionsRequestApproval { + request_id: RequestId::Integer(request_id), + params: PermissionsRequestApprovalParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "permissions-1".to_string(), + started_at_ms: 1_000, + cwd: test_path_buf("/tmp").abs(), + reason: Some("need network".to_string()), + permissions: RequestPermissionProfile { + network: Some(codex_app_server_protocol::AdditionalNetworkPermissions { + enabled: Some(true), + }), + file_system: None, + }, + }, + } +} + +fn sample_effective_permissions_approval_response( + permissions: CoreRequestPermissionProfile, + scope: CorePermissionGrantScope, +) -> CoreRequestPermissionsResponse { + CoreRequestPermissionsResponse { + permissions, + scope, + strict_auto_review: false, + } +} + +fn sample_guardian_review_completed( + review_id: &str, + target_item_id: Option<&str>, + status: GuardianApprovalReviewStatus, +) -> ServerNotification { + ServerNotification::ItemGuardianApprovalReviewCompleted( + ItemGuardianApprovalReviewCompletedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + started_at_ms: 1_000, + completed_at_ms: 1_042, + review_id: review_id.to_string(), + target_item_id: target_item_id.map(str::to_string), + decision_source: codex_app_server_protocol::AutoReviewDecisionSource::Agent, + review: GuardianApprovalReview { + status, + risk_level: None, + user_authorization: None, + rationale: None, + }, + action: GuardianApprovalReviewAction::Command { + source: AppServerGuardianCommandSource::Shell, + command: "echo hi".to_string(), + cwd: test_path_buf("/tmp").abs(), + }, + }, + ) +} + fn expected_absolute_path(path: &PathBuf) -> String { std::fs::canonicalize(path) .unwrap_or_else(|_| path.to_path_buf()) @@ -721,6 +1012,186 @@ fn app_used_event_serializes_expected_shape() { ); } +#[test] +fn accepted_line_fingerprints_event_serializes_expected_shape() { + let event = TrackEventRequest::AcceptedLineFingerprints(Box::new( + CodexAcceptedLineFingerprintsEventRequest { + event_type: "codex_accepted_line_fingerprints", + event_params: CodexAcceptedLineFingerprintsEventParams { + event_type: "codex.accepted_line_fingerprints", + turn_id: "turn-1".to_string(), + thread_id: "thread-1".to_string(), + product_surface: Some("codex".to_string()), + model_slug: Some("gpt-5.1-codex".to_string()), + completed_at: 1710000000, + repo_hash: Some("repo-hash-1".to_string()), + accepted_added_lines: 42, + accepted_deleted_lines: 40, + line_fingerprints: Vec::new(), + }, + }, + )); + + let payload = serde_json::to_value(&event).expect("serialize accepted line fingerprints event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_accepted_line_fingerprints", + "event_params": { + "event_type": "codex.accepted_line_fingerprints", + "turn_id": "turn-1", + "thread_id": "thread-1", + "product_surface": "codex", + "model_slug": "gpt-5.1-codex", + "completed_at": 1710000000, + "repo_hash": "repo-hash-1", + "accepted_added_lines": 42, + "accepted_deleted_lines": 40, + "line_fingerprints": [] + } + }) + ); +} + +#[tokio::test] +async fn reducer_emits_large_accepted_line_aggregates_without_fingerprints() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut events, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ true, + ) + .await; + events.clear(); + + let mut diff = "\ +diff --git a/src/lib.rs b/src/lib.rs +index 1111111..2222222 +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -0,0 +1,20000 @@ +" + .to_string(); + for index in 0..20_000 { + diff.push_str(&format!("+let value_{index} = {index};\n")); + } + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::TurnDiffUpdated( + TurnDiffUpdatedNotification { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + diff, + }, + ))), + &mut events, + ) + .await; + assert!(events.is_empty()); + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut events, + ) + .await; + + let accepted_line_events = events + .iter() + .filter_map(|event| match event { + TrackEventRequest::AcceptedLineFingerprints(event) => Some(event), + _ => None, + }) + .collect::>(); + assert_eq!(accepted_line_events.len(), 1); + let event = accepted_line_events[0]; + assert_eq!(event.event_params.turn_id, "turn-2"); + assert_eq!(event.event_params.thread_id, "thread-2"); + assert_eq!(event.event_params.accepted_added_lines, 20_000); + assert_eq!(event.event_params.accepted_deleted_lines, 0); + assert!(event.event_params.line_fingerprints.is_empty()); + assert!(serde_json::to_vec(event).expect("serialize event").len() < 2_100_000); +} + +#[tokio::test] +async fn reducer_emits_accepted_line_fingerprints_once_from_latest_turn_diff_on_completion() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut events, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ true, + ) + .await; + events.clear(); + + for line in ["let old_value = 1;", "let latest_value = 2;"] { + let diff = format!( + "\ +diff --git a/src/lib.rs b/src/lib.rs +index 1111111..2222222 +--- a/src/lib.rs ++++ b/src/lib.rs +@@ -0,0 +1 @@ ++{line} +" + ); + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::TurnDiffUpdated( + TurnDiffUpdatedNotification { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + diff, + }, + ))), + &mut events, + ) + .await; + } + assert!(events.is_empty()); + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut events, + ) + .await; + + let accepted_line_events = events + .iter() + .filter_map(|event| match event { + TrackEventRequest::AcceptedLineFingerprints(event) => Some(event), + _ => None, + }) + .collect::>(); + assert_eq!(accepted_line_events.len(), 1); + let event = accepted_line_events[0]; + assert_eq!(event.event_params.accepted_added_lines, 1); + assert!(event.event_params.line_fingerprints.is_empty()); +} + #[test] fn compaction_event_serializes_expected_shape() { let event = TrackEventRequest::Compaction(Box::new(CodexCompactionEventRequest { @@ -744,7 +1215,7 @@ fn compaction_event_serializes_expected_shape() { }, sample_app_server_client_metadata(), sample_runtime_metadata(), - Some("user"), + Some(ThreadSource::User), /*subagent_source*/ None, /*parent_thread_id*/ None, ), @@ -843,7 +1314,7 @@ fn thread_initialized_event_serializes_expected_shape() { }, model: "gpt-5".to_string(), ephemeral: true, - thread_source: Some("user"), + thread_source: Some(ThreadSource::User), initialization_mode: ThreadInitializationMode::New, subagent_source: None, parent_thread_id: None, @@ -884,6 +1355,181 @@ fn thread_initialized_event_serializes_expected_shape() { ); } +#[test] +fn command_execution_event_serializes_expected_shape() { + let event = TrackEventRequest::CommandExecution(CodexCommandExecutionEventRequest { + event_type: "codex_command_execution_event", + event_params: CodexCommandExecutionEventParams { + base: CodexToolItemEventBase { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + app_server_client: CodexAppServerClientMetadata { + product_client_id: "codex_tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.2.3".to_string()), + rpc_transport: AppServerRpcTransport::Websocket, + experimental_api_enabled: Some(true), + }, + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.99.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + thread_source: Some(ThreadSource::User), + subagent_source: None, + parent_thread_id: None, + tool_name: "shell".to_string(), + started_at_ms: 123_000, + completed_at_ms: 125_000, + duration_ms: Some(2000), + execution_duration_ms: Some(1900), + review_count: 0, + guardian_review_count: 0, + user_review_count: 0, + final_approval_outcome: FinalApprovalOutcome::NotNeeded, + terminal_status: ToolItemTerminalStatus::Completed, + failure_kind: None, + requested_additional_permissions: false, + requested_network_access: false, + }, + command_execution_source: CommandExecutionSource::Agent, + exit_code: Some(0), + command_total_action_count: 4, + command_read_action_count: 1, + command_list_files_action_count: 1, + command_search_action_count: 1, + command_unknown_action_count: 1, + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize command execution event"); + assert_eq!( + payload, + json!({ + "event_type": "codex_command_execution_event", + "event_params": { + "thread_id": "thread-1", + "turn_id": "turn-1", + "item_id": "item-1", + "app_server_client": { + "product_client_id": "codex_tui", + "client_name": "codex-tui", + "client_version": "1.2.3", + "rpc_transport": "websocket", + "experimental_api_enabled": true + }, + "runtime": { + "codex_rs_version": "0.99.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "thread_source": "user", + "subagent_source": null, + "parent_thread_id": null, + "tool_name": "shell", + "started_at_ms": 123000, + "completed_at_ms": 125000, + "duration_ms": 2000, + "execution_duration_ms": 1900, + "review_count": 0, + "guardian_review_count": 0, + "user_review_count": 0, + "final_approval_outcome": "not_needed", + "terminal_status": "completed", + "failure_kind": null, + "requested_additional_permissions": false, + "requested_network_access": false, + "command_execution_source": "agent", + "exit_code": 0, + "command_total_action_count": 4, + "command_read_action_count": 1, + "command_list_files_action_count": 1, + "command_search_action_count": 1, + "command_unknown_action_count": 1 + } + }) + ); +} + +#[test] +fn review_event_serializes_expected_shape() { + let event = TrackEventRequest::ReviewEvent(CodexReviewEventRequest { + event_type: "codex_review_event", + event_params: CodexReviewEventParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: None, + review_id: "review-1".to_string(), + app_server_client: CodexAppServerClientMetadata { + product_client_id: "codex_tui".to_string(), + client_name: Some("codex-tui".to_string()), + client_version: Some("1.2.3".to_string()), + rpc_transport: AppServerRpcTransport::Websocket, + experimental_api_enabled: Some(true), + }, + runtime: CodexRuntimeMetadata { + codex_rs_version: "0.99.0".to_string(), + runtime_os: "macos".to_string(), + runtime_os_version: "15.3.1".to_string(), + runtime_arch: "aarch64".to_string(), + }, + thread_source: Some(ThreadSource::Subagent), + subagent_source: Some("thread_spawn".to_string()), + parent_thread_id: Some("parent-thread-1".to_string()), + subject_kind: ReviewSubjectKind::NetworkAccess, + subject_name: "network_access".to_string(), + reviewer: Reviewer::User, + trigger: ReviewTrigger::NetworkPolicyDenial, + status: ReviewStatus::Approved, + resolution: ReviewResolution::NetworkPolicyAmendment, + started_at_ms: 123, + completed_at_ms: 125, + duration_ms: Some(2), + }, + }); + + let payload = serde_json::to_value(&event).expect("serialize review event"); + assert_eq!( + payload, + json!({ + "event_type": "codex_review_event", + "event_params": { + "thread_id": "thread-1", + "turn_id": "turn-1", + "item_id": null, + "review_id": "review-1", + "app_server_client": { + "product_client_id": "codex_tui", + "client_name": "codex-tui", + "client_version": "1.2.3", + "rpc_transport": "websocket", + "experimental_api_enabled": true + }, + "runtime": { + "codex_rs_version": "0.99.0", + "runtime_os": "macos", + "runtime_os_version": "15.3.1", + "runtime_arch": "aarch64" + }, + "thread_source": "subagent", + "subagent_source": "thread_spawn", + "parent_thread_id": "parent-thread-1", + "subject_kind": "network_access", + "subject_name": "network_access", + "reviewer": "user", + "trigger": "network_policy_denial", + "status": "approved", + "resolution": "network_policy_amendment", + "started_at_ms": 123, + "completed_at_ms": 125, + "duration_ms": 2 + } + }) + ); +} #[tokio::test] async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialized() { let mut reducer = AnalyticsReducer::default(); @@ -917,6 +1563,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize }, capabilities: Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), }, @@ -1064,6 +1711,7 @@ async fn compaction_event_ingests_custom_fact() { }, capabilities: Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), }, @@ -1090,6 +1738,7 @@ async fn compaction_event_ingests_custom_fact() { agent_nickname: None, agent_role: None, }), + Some(AppServerThreadSource::Subagent), )), }, &mut events, @@ -1176,6 +1825,7 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { }, capabilities: Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), }, @@ -1289,6 +1939,452 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { assert_eq!(payload[0]["event_params"]["review_timeout_ms"], 90_000); } +#[tokio::test] +async fn item_lifecycle_notifications_publish_command_execution_event() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + ingest_review_prerequisites(&mut reducer, &mut events).await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-1", "turn-1", + ))), + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted( + ItemStartedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + started_at_ms: 1_000, + item: sample_command_execution_item( + CommandExecutionStatus::InProgress, + /*exit_code*/ None, + /*duration_ms*/ None, + ), + }, + ))), + &mut events, + ) + .await; + assert!( + events.is_empty(), + "tool item event should emit on completion" + ); + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted( + ItemCompletedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + completed_at_ms: 1_045, + item: sample_command_execution_item_with_actions( + CommandExecutionStatus::Completed, + Some(0), + Some(42), + vec![ + CommandAction::Read { + command: "cat README.md".to_string(), + name: "README.md".to_string(), + path: test_path_buf("/tmp/README.md").abs(), + }, + CommandAction::ListFiles { + command: "ls".to_string(), + path: None, + }, + CommandAction::Search { + command: "rg TODO".to_string(), + query: Some("TODO".to_string()), + path: None, + }, + CommandAction::Unknown { + command: "cargo test".to_string(), + }, + ], + ), + }, + ))), + &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_command_execution_event"); + assert_eq!(payload[0]["event_params"]["thread_id"], "thread-1"); + assert_eq!(payload[0]["event_params"]["turn_id"], "turn-1"); + assert_eq!(payload[0]["event_params"]["item_id"], "item-1"); + assert_eq!(payload[0]["event_params"]["tool_name"], "shell"); + assert_eq!( + payload[0]["event_params"]["command_execution_source"], + "agent" + ); + assert_eq!(payload[0]["event_params"]["terminal_status"], "completed"); + assert_eq!( + payload[0]["event_params"]["final_approval_outcome"], + "unknown" + ); + assert_eq!( + payload[0]["event_params"]["failure_kind"], + serde_json::Value::Null + ); + assert_eq!(payload[0]["event_params"]["exit_code"], 0); + assert_eq!(payload[0]["event_params"]["command_total_action_count"], 4); + assert_eq!(payload[0]["event_params"]["command_read_action_count"], 1); + assert_eq!( + payload[0]["event_params"]["command_list_files_action_count"], + 1 + ); + assert_eq!(payload[0]["event_params"]["command_search_action_count"], 1); + assert_eq!( + payload[0]["event_params"]["command_unknown_action_count"], + 1 + ); + assert_eq!(payload[0]["event_params"]["started_at_ms"], 1_000); + assert_eq!(payload[0]["event_params"]["completed_at_ms"], 1_045); + assert_eq!(payload[0]["event_params"]["duration_ms"], 45); + assert_eq!(payload[0]["event_params"]["execution_duration_ms"], 42); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["client_name"], + "codex-tui" + ); + assert_eq!(payload[0]["event_params"]["thread_source"], "user"); +} + +#[tokio::test] +async fn command_execution_approval_response_publishes_user_review_event() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + ingest_review_prerequisites(&mut reducer, &mut events).await; + reducer + .ingest( + AnalyticsFact::ServerRequest { + connection_id: 7, + request: Box::new(sample_command_approval_request( + /*request_id*/ 41, /*approval_id*/ None, + )), + }, + &mut events, + ) + .await; + assert!(events.is_empty()); + + reducer + .ingest( + AnalyticsFact::ServerResponse { + completed_at_ms: 1_042, + response: Box::new(sample_command_approval_response( + /*request_id*/ 41, + CommandExecutionApprovalDecision::Accept, + )), + }, + &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_review_event"); + assert_eq!(payload[0]["event_params"]["thread_id"], "thread-1"); + assert_eq!(payload[0]["event_params"]["turn_id"], "turn-1"); + assert_eq!(payload[0]["event_params"]["item_id"], "item-1"); + assert_eq!(payload[0]["event_params"]["review_id"], "user:41"); + assert_eq!(payload[0]["event_params"]["thread_source"], "user"); + assert_eq!( + payload[0]["event_params"]["subject_kind"], + "command_execution" + ); + assert_eq!( + payload[0]["event_params"]["subject_name"], + "command_execution" + ); + assert_eq!(payload[0]["event_params"]["reviewer"], "user"); + assert_eq!(payload[0]["event_params"]["trigger"], "initial"); + assert_eq!(payload[0]["event_params"]["status"], "approved"); + assert_eq!(payload[0]["event_params"]["started_at_ms"], 1_000); + assert_eq!(payload[0]["event_params"]["completed_at_ms"], 1_042); + assert_eq!(payload[0]["event_params"]["duration_ms"], 42); +} + +#[tokio::test] +async fn permissions_reviews_emit_events_without_denormalizing_onto_tool_items() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + ingest_review_prerequisites(&mut reducer, &mut events).await; + reducer + .ingest( + AnalyticsFact::ServerRequest { + connection_id: 7, + request: Box::new(sample_permissions_approval_request(/*request_id*/ 51)), + }, + &mut events, + ) + .await; + assert!(events.is_empty()); + + reducer + .ingest( + AnalyticsFact::EffectivePermissionsApprovalResponse { + completed_at_ms: 1_042, + request_id: RequestId::Integer(51), + response: Box::new(sample_effective_permissions_approval_response( + CoreRequestPermissionProfile::default(), + CorePermissionGrantScope::Turn, + )), + }, + &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_review_event"); + assert_eq!(payload[0]["event_params"]["review_id"], "user:51"); + assert_eq!(payload[0]["event_params"]["subject_kind"], "permissions"); + assert_eq!(payload[0]["event_params"]["reviewer"], "user"); + assert_eq!(payload[0]["event_params"]["status"], "denied"); + assert_eq!(payload[0]["event_params"]["resolution"], "none"); + + events.clear(); + ingest_completed_command_execution_item(&mut reducer, &mut events, "thread-1", "permissions-1") + .await; + + let payload = serde_json::to_value(&events[0]).expect("serialize tool item event"); + assert_eq!(payload["event_params"]["item_id"], "permissions-1"); + assert_eq!(payload["event_params"]["review_count"], 0); + assert_eq!(payload["event_params"]["user_review_count"], 0); + assert_eq!(payload["event_params"]["guardian_review_count"], 0); +} + +#[tokio::test] +async fn effective_session_permissions_response_publishes_session_user_review_event() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + ingest_review_prerequisites(&mut reducer, &mut events).await; + reducer + .ingest( + AnalyticsFact::ServerRequest { + connection_id: 7, + request: Box::new(sample_permissions_approval_request(/*request_id*/ 52)), + }, + &mut events, + ) + .await; + + reducer + .ingest( + AnalyticsFact::EffectivePermissionsApprovalResponse { + completed_at_ms: 1_042, + request_id: RequestId::Integer(52), + response: Box::new(sample_effective_permissions_approval_response( + CoreRequestPermissionProfile { + network: Some(CoreNetworkPermissions { + enabled: Some(true), + }), + file_system: None, + }, + CorePermissionGrantScope::Session, + )), + }, + &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_review_event"); + assert_eq!(payload[0]["event_params"]["review_id"], "user:52"); + assert_eq!(payload[0]["event_params"]["subject_kind"], "permissions"); + assert_eq!(payload[0]["event_params"]["reviewer"], "user"); + assert_eq!(payload[0]["event_params"]["status"], "approved"); + assert_eq!(payload[0]["event_params"]["resolution"], "session_approval"); +} + +#[tokio::test] +async fn aborted_server_request_publishes_aborted_user_review_event_once() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + ingest_review_prerequisites(&mut reducer, &mut events).await; + reducer + .ingest( + AnalyticsFact::ServerRequest { + connection_id: 7, + request: Box::new(sample_command_approval_request( + /*request_id*/ 61, /*approval_id*/ None, + )), + }, + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::ServerRequestAborted { + completed_at_ms: 1_042, + request_id: RequestId::Integer(61), + }, + &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_params"]["review_id"], "user:61"); + assert_eq!(payload[0]["event_params"]["status"], "aborted"); + assert_eq!(payload[0]["event_params"]["resolution"], "none"); + + events.clear(); + reducer + .ingest( + AnalyticsFact::ServerResponse { + completed_at_ms: 1_043, + response: Box::new(sample_command_approval_response( + /*request_id*/ 61, + CommandExecutionApprovalDecision::Accept, + )), + }, + &mut events, + ) + .await; + assert!(events.is_empty()); +} + +#[tokio::test] +async fn guardian_completed_notification_publishes_review_event_with_thread_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + ingest_review_prerequisites(&mut reducer, &mut events).await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_guardian_review_completed( + "guardian-review-1", + Some("item-1"), + GuardianApprovalReviewStatus::Denied, + ))), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events[0]).expect("serialize review event"); + assert_eq!(payload["event_type"], "codex_review_event"); + assert_eq!(payload["event_params"]["review_id"], "guardian-review-1"); + assert_eq!(payload["event_params"]["item_id"], "item-1"); + assert_eq!(payload["event_params"]["thread_source"], "user"); + assert_eq!(payload["event_params"]["subject_kind"], "command_execution"); + assert_eq!(payload["event_params"]["reviewer"], "guardian"); + assert_eq!(payload["event_params"]["status"], "denied"); + assert_eq!(payload["event_params"]["started_at_ms"], 1_000); + assert_eq!(payload["event_params"]["completed_at_ms"], 1_042); + assert_eq!(payload["event_params"]["duration_ms"], 42); +} + +#[tokio::test] +async fn terminal_reviews_denormalize_counts_onto_tool_item_events() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + ingest_review_prerequisites(&mut reducer, &mut events).await; + reducer + .ingest( + AnalyticsFact::ServerRequest { + connection_id: 7, + request: Box::new(sample_command_approval_request( + /*request_id*/ 71, /*approval_id*/ None, + )), + }, + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::ServerResponse { + completed_at_ms: 1_042, + response: Box::new(sample_command_approval_response( + /*request_id*/ 71, + CommandExecutionApprovalDecision::AcceptForSession, + )), + }, + &mut events, + ) + .await; + events.clear(); + + ingest_completed_command_execution_item(&mut reducer, &mut events, "thread-1", "item-1").await; + + let payload = serde_json::to_value(&events[0]).expect("serialize tool item event"); + assert_eq!(payload["event_params"]["review_count"], 1); + assert_eq!(payload["event_params"]["user_review_count"], 1); + assert_eq!(payload["event_params"]["guardian_review_count"], 0); + assert_eq!( + payload["event_params"]["final_approval_outcome"], + "user_approved_for_session" + ); +} + +#[tokio::test] +async fn item_review_summaries_do_not_cross_threads_with_reused_item_ids() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + ingest_review_prerequisites(&mut reducer, &mut events).await; + reducer + .ingest( + AnalyticsFact::ClientResponse { + connection_id: 7, + request_id: RequestId::Integer(2), + response: Box::new(sample_thread_start_response( + "thread-2", /*ephemeral*/ false, "gpt-5", + )), + }, + &mut events, + ) + .await; + events.clear(); + + reducer + .ingest( + AnalyticsFact::ServerRequest { + connection_id: 7, + request: Box::new(sample_command_approval_request( + /*request_id*/ 72, /*approval_id*/ None, + )), + }, + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::ServerResponse { + completed_at_ms: 1_042, + response: Box::new(sample_command_approval_response( + /*request_id*/ 72, + CommandExecutionApprovalDecision::Accept, + )), + }, + &mut events, + ) + .await; + events.clear(); + + ingest_completed_command_execution_item(&mut reducer, &mut events, "thread-2", "item-1").await; + + let payload = serde_json::to_value(&events[0]).expect("serialize tool item event"); + assert_eq!(payload["event_params"]["thread_id"], "thread-2"); + assert_eq!(payload["event_params"]["item_id"], "item-1"); + assert_eq!(payload["event_params"]["review_count"], 0); + assert_eq!(payload["event_params"]["user_review_count"], 0); + assert_eq!(payload["event_params"]["guardian_review_count"], 0); + assert_eq!(payload["event_params"]["final_approval_outcome"], "unknown"); +} + #[test] fn subagent_thread_started_review_serializes_expected_shape() { let event = TrackEventRequest::ThreadInitialized(subagent_thread_started_event_request( @@ -1572,6 +2668,88 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() { ); } +#[tokio::test] +async fn subagent_tool_items_inherit_parent_connection_metadata() { + let mut reducer = AnalyticsReducer::default(); + let mut events = Vec::new(); + + ingest_review_prerequisites(&mut reducer, &mut events).await; + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted( + SubAgentThreadStartedInput { + thread_id: "thread-subagent".to_string(), + parent_thread_id: Some("thread-1".to_string()), + product_client_id: "codex-tui".to_string(), + client_name: "codex-tui".to_string(), + client_version: "1.0.0".to_string(), + model: "gpt-5".to_string(), + ephemeral: false, + subagent_source: SubAgentSource::Review, + created_at: 128, + }, + )), + &mut events, + ) + .await; + events.clear(); + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-subagent", + "turn-subagent", + ))), + &mut events, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ItemStarted( + ItemStartedNotification { + thread_id: "thread-subagent".to_string(), + turn_id: "turn-subagent".to_string(), + started_at_ms: 1_000, + item: sample_command_execution_item( + CommandExecutionStatus::InProgress, + /*exit_code*/ None, + /*duration_ms*/ None, + ), + }, + ))), + &mut events, + ) + .await; + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted( + ItemCompletedNotification { + thread_id: "thread-subagent".to_string(), + turn_id: "turn-subagent".to_string(), + completed_at_ms: 1_042, + item: sample_command_execution_item( + CommandExecutionStatus::Completed, + Some(0), + Some(42), + ), + }, + ))), + &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_command_execution_event"); + assert_eq!(payload[0]["event_params"]["thread_source"], "subagent"); + assert_eq!(payload[0]["event_params"]["subagent_source"], "review"); + assert_eq!(payload[0]["event_params"]["parent_thread_id"], "thread-1"); + assert_eq!( + payload[0]["event_params"]["app_server_client"]["client_name"], + "codex-tui" + ); +} + #[test] fn plugin_used_event_serializes_expected_shape() { let tracking = TrackEventsContext { @@ -1816,6 +2994,7 @@ async fn reducer_ingests_skill_invoked_fact() { skill_name: "doc".to_string(), skill_scope: codex_protocol::protocol::SkillScope::User, skill_path, + plugin_id: None, invocation_type: InvocationType::Explicit, }], })), @@ -1833,8 +3012,10 @@ async fn reducer_ingests_skill_invoked_fact() { "event_params": { "product_client_id": originator().value, "skill_scope": "user", + "plugin_id": null, "repo_url": null, "thread_id": "thread-1", + "turn_id": "turn-1", "invoke_type": "explicit", "model_slug": "gpt-5" } @@ -1842,6 +3023,41 @@ async fn reducer_ingests_skill_invoked_fact() { ); } +#[tokio::test] +async fn reducer_includes_plugin_id_for_plugin_skill_invocations() { + 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/plugins/cache/test/sample/skills/doc/SKILL.md"); + + reducer + .ingest( + AnalyticsFact::Custom(CustomAnalyticsFact::SkillInvoked(SkillInvokedInput { + tracking, + invocations: vec![SkillInvocation { + skill_name: "sample:doc".to_string(), + skill_scope: codex_protocol::protocol::SkillScope::User, + skill_path, + plugin_id: Some("sample@test".to_string()), + invocation_type: InvocationType::Explicit, + }], + })), + &mut events, + ) + .await; + + let payload = serde_json::to_value(&events).expect("serialize events"); + assert_eq!( + payload[0]["event_params"]["plugin_id"], + json!("sample@test") + ); +} + #[tokio::test] async fn reducer_ingests_hook_run_fact() { let mut reducer = AnalyticsReducer::default(); @@ -1972,7 +3188,7 @@ fn turn_event_serializes_expected_shape() { runtime: sample_runtime_metadata(), submission_type: None, ephemeral: false, - thread_source: Some("user".to_string()), + thread_source: Some(ThreadSource::User), initialization_mode: ThreadInitializationMode::New, subagent_source: None, parent_thread_id: None, @@ -2360,6 +3576,17 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["num_input_images"], json!(1)); assert_eq!(payload["event_params"]["status"], json!("completed")); assert_eq!(payload["event_params"]["steer_count"], json!(0)); + assert_eq!(payload["event_params"]["total_tool_call_count"], json!(0)); + assert_eq!(payload["event_params"]["shell_command_count"], json!(0)); + assert_eq!(payload["event_params"]["file_change_count"], json!(0)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(0)); + assert_eq!(payload["event_params"]["dynamic_tool_call_count"], json!(0)); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(0) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(0)); + assert_eq!(payload["event_params"]["image_generation_count"], json!(0)); assert_eq!(payload["event_params"]["started_at"], json!(455)); assert_eq!(payload["event_params"]["completed_at"], json!(456)); assert_eq!(payload["event_params"]["duration_ms"], json!(1234)); @@ -2373,6 +3600,158 @@ async fn turn_lifecycle_emits_turn_event() { assert_eq!(payload["event_params"]["total_tokens"], json!(321)); } +#[tokio::test] +async fn turn_event_counts_completed_tool_items() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + ingest_turn_prerequisites( + &mut reducer, + &mut out, + /*include_initialize*/ true, + /*include_resolved_config*/ true, + /*include_started*/ true, + /*include_token_usage*/ false, + ) + .await; + + let completed_tool_items = vec![ + sample_command_execution_item(CommandExecutionStatus::Completed, Some(0), Some(1)), + ThreadItem::FileChange { + id: "file-change-1".to_string(), + changes: Vec::new(), + status: PatchApplyStatus::Completed, + }, + ThreadItem::McpToolCall { + id: "mcp-1".to_string(), + server: "server".to_string(), + tool: "search".to_string(), + status: McpToolCallStatus::Completed, + arguments: json!({}), + mcp_app_resource_uri: None, + result: None, + error: None, + duration_ms: Some(2), + }, + ThreadItem::DynamicToolCall { + id: "dynamic-1".to_string(), + namespace: None, + tool: "render".to_string(), + arguments: json!({}), + status: DynamicToolCallStatus::Completed, + content_items: None, + success: Some(true), + duration_ms: Some(3), + }, + ThreadItem::CollabAgentToolCall { + id: "collab-1".to_string(), + tool: CollabAgentTool::SpawnAgent, + status: CollabAgentToolCallStatus::Completed, + sender_thread_id: "thread-2".to_string(), + receiver_thread_ids: vec!["thread-child".to_string()], + prompt: Some("help".to_string()), + model: Some("gpt-5".to_string()), + reasoning_effort: None, + agents_states: Default::default(), + }, + ThreadItem::WebSearch { + id: "web-1".to_string(), + query: "codex".to_string(), + action: None, + }, + ThreadItem::ImageGeneration { + id: "image-1".to_string(), + status: "completed".to_string(), + revised_prompt: None, + result: "ok".to_string(), + saved_path: None, + }, + ]; + + for item in completed_tool_items { + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted( + ItemCompletedNotification { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + completed_at_ms: 1_000, + item, + }, + ))), + &mut out, + ) + .await; + } + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + let turn_event = out + .iter() + .find(|event| matches!(event, TrackEventRequest::TurnEvent(_))) + .expect("turn event should be emitted"); + let payload = serde_json::to_value(turn_event).expect("serialize turn event"); + assert_eq!(payload["event_params"]["total_tool_call_count"], json!(7)); + assert_eq!(payload["event_params"]["shell_command_count"], json!(1)); + assert_eq!(payload["event_params"]["file_change_count"], json!(1)); + assert_eq!(payload["event_params"]["mcp_tool_call_count"], json!(1)); + assert_eq!(payload["event_params"]["dynamic_tool_call_count"], json!(1)); + assert_eq!( + payload["event_params"]["subagent_tool_call_count"], + json!(1) + ); + assert_eq!(payload["event_params"]["web_search_count"], json!(1)); + assert_eq!(payload["event_params"]["image_generation_count"], json!(1)); +} + +#[tokio::test] +async fn item_completed_without_turn_state_does_not_create_turn_state() { + let mut reducer = AnalyticsReducer::default(); + let mut out = Vec::new(); + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(ServerNotification::ItemCompleted( + ItemCompletedNotification { + thread_id: "thread-2".to_string(), + turn_id: "turn-2".to_string(), + completed_at_ms: 1_000, + item: sample_command_execution_item( + CommandExecutionStatus::Completed, + Some(0), + Some(1), + ), + }, + ))), + &mut out, + ) + .await; + + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_completed_notification( + "thread-2", + "turn-2", + AppServerTurnStatus::Completed, + /*codex_error_info*/ None, + ))), + &mut out, + ) + .await; + + assert!(out.is_empty()); +} + #[tokio::test] async fn accepted_steers_increment_turn_steer_count() { let mut reducer = AnalyticsReducer::default(); diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index d54c53ede9..fbcfa32dc5 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -30,8 +30,10 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerResponse; use codex_login::AuthManager; +use codex_login::CodexAuth; use codex_login::default_client::create_client; use codex_plugin::PluginTelemetryMetadata; +use codex_protocol::request_permissions::RequestPermissionsResponse; use std::collections::HashSet; use std::sync::Arc; use std::sync::Mutex; @@ -171,9 +173,10 @@ impl AnalyticsEventsClient { &self, tracking: &GuardianReviewTrackContext, result: GuardianReviewAnalyticsResult, + completed_at_ms: u64, ) { self.record_fact(AnalyticsFact::Custom(CustomAnalyticsFact::GuardianReview( - Box::new(tracking.event_params(result)), + Box::new(tracking.event_params(result, completed_at_ms)), ))); } @@ -333,10 +336,6 @@ impl AnalyticsEventsClient { }); } - pub fn track_notification(&self, notification: ServerNotification) { - self.record_fact(AnalyticsFact::Notification(Box::new(notification))); - } - pub fn track_server_request(&self, connection_id: u64, request: ServerRequest) { self.record_fact(AnalyticsFact::ServerRequest { connection_id, @@ -344,11 +343,48 @@ impl AnalyticsEventsClient { }); } - pub fn track_server_response(&self, response: ServerResponse) { + pub fn track_server_response(&self, completed_at_ms: u64, response: ServerResponse) { self.record_fact(AnalyticsFact::ServerResponse { + completed_at_ms, response: Box::new(response), }); } + + pub fn track_effective_permissions_approval_response( + &self, + completed_at_ms: u64, + request_id: RequestId, + response: RequestPermissionsResponse, + ) { + self.record_fact(AnalyticsFact::EffectivePermissionsApprovalResponse { + completed_at_ms, + request_id, + response: Box::new(response), + }); + } + + pub fn track_server_request_aborted(&self, completed_at_ms: u64, request_id: RequestId) { + self.record_fact(AnalyticsFact::ServerRequestAborted { + completed_at_ms, + request_id, + }); + } + + pub fn track_notification(&self, notification: ServerNotification) { + if !matches!( + notification, + ServerNotification::TurnStarted(_) + | ServerNotification::TurnCompleted(_) + | ServerNotification::TurnDiffUpdated(_) + | ServerNotification::ItemStarted(_) + | ServerNotification::ItemCompleted(_) + | ServerNotification::ItemGuardianApprovalReviewStarted(_) + | ServerNotification::ItemGuardianApprovalReviewCompleted(_) + ) { + return; + } + self.record_fact(AnalyticsFact::Notification(Box::new(notification))); + } } async fn send_track_events( @@ -359,6 +395,7 @@ async fn send_track_events( if events.is_empty() { return; } + let Some(auth) = auth_manager.auth().await else { return; }; @@ -368,12 +405,45 @@ async fn send_track_events( let base_url = base_url.trim_end_matches('/'); let url = format!("{base_url}/codex/analytics-events/events"); + for events in track_event_request_batches(events) { + send_track_events_request(&auth, &url, events).await; + } +} + +fn track_event_request_batches(events: Vec) -> Vec> { + let mut batches = Vec::new(); + let mut current_batch = Vec::new(); + + for event in events { + if event.should_send_in_isolated_request() { + if !current_batch.is_empty() { + batches.push(current_batch); + current_batch = Vec::new(); + } + batches.push(vec![event]); + } else { + current_batch.push(event); + } + } + + if !current_batch.is_empty() { + batches.push(current_batch); + } + + batches +} + +async fn send_track_events_request(auth: &CodexAuth, url: &str, events: Vec) { + if events.is_empty() { + return; + } + let payload = TrackEventsRequest { events }; let response = create_client() - .post(&url) + .post(url) .timeout(ANALYTICS_EVENTS_TIMEOUT) - .headers(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers()) + .headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers()) .header("Content-Type", "application/json") .json(&payload) .send() diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 4b6fb54e95..71c46d808a 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -1,6 +1,13 @@ use super::AnalyticsEventsClient; use super::AnalyticsEventsQueue; +use super::track_event_request_batches; +use crate::events::CodexAcceptedLineFingerprintsEventParams; +use crate::events::CodexAcceptedLineFingerprintsEventRequest; +use crate::events::SkillInvocationEventParams; +use crate::events::SkillInvocationEventRequest; +use crate::events::TrackEventRequest; use crate::facts::AnalyticsFact; +use crate::facts::InvocationType; use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientRequest; @@ -31,6 +38,44 @@ use std::sync::Mutex; use tokio::sync::mpsc; use tokio::sync::mpsc::error::TryRecvError; +fn sample_accepted_line_fingerprint_event(thread_id: &str) -> TrackEventRequest { + TrackEventRequest::AcceptedLineFingerprints(Box::new( + CodexAcceptedLineFingerprintsEventRequest { + event_type: "codex_accepted_line_fingerprints", + event_params: CodexAcceptedLineFingerprintsEventParams { + event_type: "codex.accepted_line_fingerprints", + turn_id: "turn-1".to_string(), + thread_id: thread_id.to_string(), + product_surface: Some("codex".to_string()), + model_slug: Some("gpt-5.1-codex".to_string()), + completed_at: 1, + repo_hash: None, + accepted_added_lines: 1, + accepted_deleted_lines: 0, + line_fingerprints: Vec::new(), + }, + }, + )) +} + +fn sample_regular_track_event(thread_id: &str) -> TrackEventRequest { + TrackEventRequest::SkillInvocation(SkillInvocationEventRequest { + event_type: "skill_invocation", + skill_id: format!("skill-{thread_id}"), + skill_name: "doc".to_string(), + event_params: SkillInvocationEventParams { + product_client_id: None, + skill_scope: None, + plugin_id: None, + repo_url: None, + thread_id: Some(thread_id.to_string()), + turn_id: Some("turn-1".to_string()), + invoke_type: Some(InvocationType::Explicit), + model_slug: Some("gpt-5.1-codex".to_string()), + }, + }) +} + fn client_with_receiver() -> (AnalyticsEventsClient, mpsc::Receiver) { let (sender, receiver) = mpsc::channel(8); let queue = AnalyticsEventsQueue { @@ -76,6 +121,7 @@ fn sample_thread_archive_request() -> ClientRequest { fn sample_thread(thread_id: &str) -> Thread { Thread { id: thread_id.to_string(), + session_id: format!("session-{thread_id}"), forked_from_id: None, preview: "first prompt".to_string(), ephemeral: false, @@ -87,6 +133,7 @@ fn sample_thread(thread_id: &str) -> Thread { cwd: test_path_buf("/tmp").abs(), cli_version: "0.0.0".to_string(), source: AppServerSessionSource::Exec, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, @@ -154,6 +201,7 @@ fn sample_turn_start_response() -> ClientResponsePayload { ClientResponsePayload::TurnStart(TurnStartResponse { turn: Turn { id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, @@ -219,3 +267,23 @@ fn track_response_only_enqueues_analytics_relevant_responses() { ); assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty))); } + +#[test] +fn track_event_request_batches_only_isolates_accepted_line_fingerprint_events() { + let batches = track_event_request_batches(vec![ + sample_regular_track_event("thread-1"), + sample_regular_track_event("thread-2"), + sample_accepted_line_fingerprint_event("thread-3"), + sample_accepted_line_fingerprint_event("thread-4"), + sample_regular_track_event("thread-5"), + sample_regular_track_event("thread-6"), + ]); + + assert_eq!(batches.len(), 4); + assert_eq!(batches[0].len(), 2); + assert_eq!(batches[1].len(), 1); + assert_eq!(batches[2].len(), 1); + assert_eq!(batches[3].len(), 2); + assert!(batches[1][0].should_send_in_isolated_request()); + assert!(batches[2][0].should_send_in_isolated_request()); +} diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 8bd9440299..d107a5a367 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1,5 +1,6 @@ use std::time::Instant; +use crate::facts::AcceptedLineFingerprint; use crate::facts::AppInvocation; use crate::facts::CodexCompactionEvent; use crate::facts::CompactionImplementation; @@ -18,8 +19,9 @@ use crate::facts::TurnStatus; use crate::facts::TurnSteerRejectionReason; use crate::facts::TurnSteerResult; use crate::facts::TurnSubmissionType; -use crate::now_unix_seconds; +use crate::now_unix_millis; use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::CommandExecutionSource; use codex_login::default_client::originator; use codex_plugin::PluginTelemetryMetadata; use codex_protocol::approvals::NetworkApprovalProtocol; @@ -33,6 +35,7 @@ use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::HookRunStatus; use codex_protocol::protocol::HookSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsage; use serde::Serialize; @@ -61,6 +64,16 @@ pub(crate) enum TrackEventRequest { Compaction(Box), TurnEvent(Box), TurnSteer(CodexTurnSteerEventRequest), + CommandExecution(CodexCommandExecutionEventRequest), + FileChange(CodexFileChangeEventRequest), + McpToolCall(CodexMcpToolCallEventRequest), + DynamicToolCall(CodexDynamicToolCallEventRequest), + CollabAgentToolCall(CodexCollabAgentToolCallEventRequest), + WebSearch(CodexWebSearchEventRequest), + ImageGeneration(CodexImageGenerationEventRequest), + AcceptedLineFingerprints(Box), + #[allow(dead_code)] + ReviewEvent(CodexReviewEventRequest), PluginUsed(CodexPluginUsedEventRequest), PluginInstalled(CodexPluginEventRequest), PluginUninstalled(CodexPluginEventRequest), @@ -68,6 +81,32 @@ pub(crate) enum TrackEventRequest { PluginDisabled(CodexPluginEventRequest), } +impl TrackEventRequest { + pub(crate) fn should_send_in_isolated_request(&self) -> bool { + matches!(self, Self::AcceptedLineFingerprints(_)) + } +} + +#[derive(Serialize)] +pub(crate) struct CodexAcceptedLineFingerprintsEventParams { + pub(crate) event_type: &'static str, + pub(crate) turn_id: String, + pub(crate) thread_id: String, + pub(crate) product_surface: Option, + pub(crate) model_slug: Option, + pub(crate) completed_at: u64, + pub(crate) repo_hash: Option, + pub(crate) accepted_added_lines: u64, + pub(crate) accepted_deleted_lines: u64, + pub(crate) line_fingerprints: Vec, +} + +#[derive(Serialize)] +pub(crate) struct CodexAcceptedLineFingerprintsEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexAcceptedLineFingerprintsEventParams, +} + #[derive(Serialize)] pub(crate) struct SkillInvocationEventRequest { pub(crate) event_type: &'static str, @@ -80,8 +119,10 @@ pub(crate) struct SkillInvocationEventRequest { pub(crate) struct SkillInvocationEventParams { pub(crate) product_client_id: Option, pub(crate) skill_scope: Option, + pub(crate) plugin_id: Option, pub(crate) repo_url: Option, pub(crate) thread_id: Option, + pub(crate) turn_id: Option, pub(crate) invoke_type: Option, pub(crate) model_slug: Option, } @@ -110,7 +151,7 @@ pub(crate) struct ThreadInitializedEventParams { pub(crate) runtime: CodexRuntimeMetadata, pub(crate) model: String, pub(crate) ephemeral: bool, - pub(crate) thread_source: Option<&'static str>, + pub(crate) thread_source: Option, pub(crate) initialization_mode: ThreadInitializationMode, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, @@ -248,7 +289,7 @@ pub struct GuardianReviewTrackContext { approval_request_source: GuardianApprovalRequestSource, reviewed_action: GuardianReviewedAction, review_timeout_ms: u64, - started_at: u64, + pub started_at_ms: u64, started_instant: Instant, } @@ -270,7 +311,7 @@ impl GuardianReviewTrackContext { approval_request_source, reviewed_action, review_timeout_ms, - started_at: now_unix_seconds(), + started_at_ms: now_unix_millis(), started_instant: Instant::now(), } } @@ -278,6 +319,7 @@ impl GuardianReviewTrackContext { pub(crate) fn event_params( &self, result: GuardianReviewAnalyticsResult, + completed_at_ms: u64, ) -> GuardianReviewEventParams { GuardianReviewEventParams { thread_id: self.thread_id.clone(), @@ -303,8 +345,8 @@ impl GuardianReviewTrackContext { tool_call_count: None, time_to_first_token_ms: result.time_to_first_token_ms, completion_latency_ms: Some(self.started_instant.elapsed().as_millis() as u64), - started_at: self.started_at, - completed_at: Some(now_unix_seconds()), + started_at: self.started_at_ms / 1_000, + completed_at: Some(completed_at_ms / 1_000), input_tokens: result.token_usage.as_ref().map(|usage| usage.input_tokens), cached_input_tokens: result .token_usage @@ -384,6 +426,276 @@ pub(crate) struct GuardianReviewEventPayload { pub(crate) guardian_review: GuardianReviewEventParams, } +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum FinalApprovalOutcome { + Unknown, + NotNeeded, + ConfigAllowed, + PolicyForbidden, + GuardianApproved, + GuardianDenied, + GuardianAborted, + UserApproved, + UserApprovedForSession, + UserDenied, + UserAborted, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolItemTerminalStatus { + Completed, + Failed, + Rejected, + Interrupted, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ToolItemFailureKind { + ToolError, + ApprovalDenied, + ApprovalAborted, + SandboxDenied, + PolicyForbidden, +} + +#[derive(Serialize)] +pub(crate) struct CodexToolItemEventBase { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + /// App-server ThreadItem.id. For tool-originated items this generally + /// corresponds to the originating core call_id. + pub(crate) item_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) thread_source: Option, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) tool_name: String, + pub(crate) started_at_ms: u64, + pub(crate) completed_at_ms: u64, + // Observed item lifecycle duration. This may undercount end-to-end execution + // for tools where app-server only sees part of the upstream flow. + pub(crate) duration_ms: Option, + pub(crate) execution_duration_ms: Option, + pub(crate) review_count: u64, + pub(crate) guardian_review_count: u64, + pub(crate) user_review_count: u64, + pub(crate) final_approval_outcome: FinalApprovalOutcome, + pub(crate) terminal_status: ToolItemTerminalStatus, + pub(crate) failure_kind: Option, + pub(crate) requested_additional_permissions: bool, + pub(crate) requested_network_access: bool, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ReviewSubjectKind { + CommandExecution, + FileChange, + McpToolCall, + Permissions, + NetworkAccess, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum Reviewer { + Guardian, + User, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ReviewTrigger { + Initial, + SandboxDenial, + NetworkPolicyDenial, + ExecveIntercept, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ReviewStatus { + Approved, + Denied, + Aborted, + TimedOut, +} + +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ReviewResolution { + None, + SessionApproval, + ExecPolicyAmendment, + NetworkPolicyAmendment, +} + +#[derive(Serialize)] +pub(crate) struct CodexReviewEventParams { + pub(crate) thread_id: String, + pub(crate) turn_id: String, + pub(crate) item_id: Option, + pub(crate) review_id: String, + pub(crate) app_server_client: CodexAppServerClientMetadata, + pub(crate) runtime: CodexRuntimeMetadata, + pub(crate) thread_source: Option, + pub(crate) subagent_source: Option, + pub(crate) parent_thread_id: Option, + pub(crate) subject_kind: ReviewSubjectKind, + pub(crate) subject_name: String, + pub(crate) reviewer: Reviewer, + pub(crate) trigger: ReviewTrigger, + pub(crate) status: ReviewStatus, + pub(crate) resolution: ReviewResolution, + pub(crate) started_at_ms: u64, + pub(crate) completed_at_ms: u64, + pub(crate) duration_ms: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexReviewEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexReviewEventParams, +} +#[allow(dead_code)] +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum WebSearchActionKind { + Search, + OpenPage, + FindInPage, + Other, +} + +#[derive(Serialize)] +pub(crate) struct CodexCommandExecutionEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) command_execution_source: CommandExecutionSource, + pub(crate) exit_code: Option, + pub(crate) command_total_action_count: u64, + pub(crate) command_read_action_count: u64, + pub(crate) command_list_files_action_count: u64, + pub(crate) command_search_action_count: u64, + pub(crate) command_unknown_action_count: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexCommandExecutionEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexCommandExecutionEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexFileChangeEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) file_change_count: u64, + pub(crate) file_add_count: u64, + pub(crate) file_update_count: u64, + pub(crate) file_delete_count: u64, + pub(crate) file_move_count: u64, +} + +#[derive(Serialize)] +pub(crate) struct CodexFileChangeEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexFileChangeEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexMcpToolCallEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) mcp_server_name: String, + pub(crate) mcp_tool_name: String, + pub(crate) mcp_error_present: bool, +} + +#[derive(Serialize)] +pub(crate) struct CodexMcpToolCallEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexMcpToolCallEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexDynamicToolCallEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) dynamic_tool_name: String, + pub(crate) success: Option, + pub(crate) output_content_item_count: Option, + pub(crate) output_text_item_count: Option, + pub(crate) output_image_item_count: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexDynamicToolCallEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexDynamicToolCallEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexCollabAgentToolCallEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) sender_thread_id: String, + pub(crate) receiver_thread_count: u64, + pub(crate) receiver_thread_ids: Option>, + pub(crate) requested_model: Option, + pub(crate) requested_reasoning_effort: Option, + pub(crate) agent_state_count: Option, + pub(crate) completed_agent_count: Option, + pub(crate) failed_agent_count: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexCollabAgentToolCallEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexCollabAgentToolCallEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexWebSearchEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) web_search_action: Option, + pub(crate) query_present: bool, + pub(crate) query_count: Option, +} + +#[derive(Serialize)] +pub(crate) struct CodexWebSearchEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexWebSearchEventParams, +} + +#[derive(Serialize)] +pub(crate) struct CodexImageGenerationEventParams { + #[serde(flatten)] + pub(crate) base: CodexToolItemEventBase, + pub(crate) revised_prompt_present: bool, + pub(crate) saved_path_present: bool, +} + +#[derive(Serialize)] +pub(crate) struct CodexImageGenerationEventRequest { + pub(crate) event_type: &'static str, + pub(crate) event_params: CodexImageGenerationEventParams, +} + #[derive(Serialize)] pub(crate) struct CodexAppMetadata { pub(crate) connector_id: Option, @@ -429,7 +741,7 @@ pub(crate) struct CodexCompactionEventParams { pub(crate) turn_id: String, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, - pub(crate) thread_source: Option<&'static str>, + pub(crate) thread_source: Option, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, pub(crate) trigger: CompactionTrigger, @@ -462,7 +774,7 @@ pub(crate) struct CodexTurnEventParams { pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, pub(crate) ephemeral: bool, - pub(crate) thread_source: Option, + pub(crate) thread_source: Option, pub(crate) initialization_mode: ThreadInitializationMode, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, @@ -482,8 +794,6 @@ pub(crate) struct CodexTurnEventParams { pub(crate) status: Option, pub(crate) turn_error: Option, pub(crate) steer_count: Option, - // TODO(rhan-oai): Populate these once tool-call accounting is emitted from - // core; the schema is reserved but these fields are currently always None. pub(crate) total_tool_call_count: Option, pub(crate) shell_command_count: Option, pub(crate) file_change_count: Option, @@ -515,7 +825,7 @@ pub(crate) struct CodexTurnSteerEventParams { pub(crate) accepted_turn_id: Option, pub(crate) app_server_client: CodexAppServerClientMetadata, pub(crate) runtime: CodexRuntimeMetadata, - pub(crate) thread_source: Option, + pub(crate) thread_source: Option, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, pub(crate) num_input_images: usize, @@ -618,7 +928,7 @@ pub(crate) fn codex_compaction_event_params( input: CodexCompactionEvent, app_server_client: CodexAppServerClientMetadata, runtime: CodexRuntimeMetadata, - thread_source: Option<&'static str>, + thread_source: Option, subagent_source: Option, parent_thread_id: Option, ) -> CodexCompactionEventParams { @@ -676,6 +986,8 @@ fn analytics_hook_event_name(event_name: HookEventName) -> &'static str { HookEventName::PreToolUse => "PreToolUse", HookEventName::PermissionRequest => "PermissionRequest", HookEventName::PostToolUse => "PostToolUse", + HookEventName::PreCompact => "PreCompact", + HookEventName::PostCompact => "PostCompact", HookEventName::SessionStart => "SessionStart", HookEventName::UserPromptSubmit => "UserPromptSubmit", HookEventName::Stop => "Stop", @@ -722,7 +1034,7 @@ pub(crate) fn subagent_thread_started_event_request( runtime: current_runtime_metadata(), model: input.model, ephemeral: input.ephemeral, - thread_source: Some("subagent"), + thread_source: Some(ThreadSource::Subagent), initialization_mode: ThreadInitializationMode::New, subagent_source: Some(subagent_source_name(&input.subagent_source)), parent_thread_id: input diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 424dd523b2..d7e2c069d6 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -25,9 +25,16 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; +use codex_protocol::request_permissions::RequestPermissionsResponse; use serde::Serialize; use std::path::PathBuf; +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct AcceptedLineFingerprint { + pub path_hash: String, + pub line_hash: String, +} + #[derive(Clone)] pub struct TrackEventsContext { pub model_slug: String, @@ -173,6 +180,7 @@ pub struct SkillInvocation { pub skill_name: String, pub skill_scope: SkillScope, pub skill_path: PathBuf, + pub plugin_id: Option, pub invocation_type: InvocationType, } @@ -295,8 +303,18 @@ pub(crate) enum AnalyticsFact { request: Box, }, ServerResponse { + completed_at_ms: u64, response: Box, }, + EffectivePermissionsApprovalResponse { + completed_at_ms: u64, + request_id: RequestId, + response: Box, + }, + ServerRequestAborted { + completed_at_ms: u64, + request_id: RequestId, + }, Notification(Box), // Facts that do not naturally exist on the app-server protocol surface, or // would require non-trivial protocol reshaping on this branch. diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs index ed0f1036ca..a33ca7b9e3 100644 --- a/codex-rs/analytics/src/lib.rs +++ b/codex-rs/analytics/src/lib.rs @@ -1,3 +1,4 @@ +mod accepted_lines; mod client; mod events; mod facts; @@ -6,6 +7,8 @@ mod reducer; use std::time::SystemTime; use std::time::UNIX_EPOCH; +pub use accepted_lines::accepted_line_fingerprints_from_unified_diff; +pub use accepted_lines::fingerprint_hash; pub use client::AnalyticsEventsClient; pub use events::AppServerRpcTransport; pub use events::GuardianApprovalRequestSource; @@ -17,6 +20,7 @@ pub use events::GuardianReviewSessionKind; pub use events::GuardianReviewTerminalStatus; pub use events::GuardianReviewTrackContext; pub use events::GuardianReviewedAction; +pub use facts::AcceptedLineFingerprint; pub use facts::AnalyticsJsonRpcError; pub use facts::AppInvocation; pub use facts::CodexCompactionEvent; @@ -51,3 +55,27 @@ pub fn now_unix_seconds() -> u64 { .unwrap_or_default() .as_secs() } + +pub fn now_unix_millis() -> u64 { + u64::try_from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + ) + .unwrap_or(u64::MAX) +} + +pub(crate) fn serialize_enum_as_string(value: &T) -> Option { + serde_json::to_value(value) + .ok() + .and_then(|value| value.as_str().map(str::to_string)) +} + +pub(crate) fn usize_to_u64(value: usize) -> u64 { + u64::try_from(value).unwrap_or(u64::MAX) +} + +pub(crate) fn option_i64_to_u64(value: Option) -> Option { + value.and_then(|value| u64::try_from(value).ok()) +} diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index b1dc822d43..d072720d10 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -1,24 +1,54 @@ +use crate::accepted_lines::AcceptedLineFingerprintEventInput; +use crate::accepted_lines::accepted_line_fingerprint_event_requests; +use crate::accepted_lines::accepted_line_fingerprints_from_unified_diff; +use crate::accepted_lines::accepted_line_repo_hash_for_cwd; use crate::events::AppServerRpcTransport; use crate::events::CodexAppMentionedEventRequest; use crate::events::CodexAppServerClientMetadata; use crate::events::CodexAppUsedEventRequest; +use crate::events::CodexCollabAgentToolCallEventParams; +use crate::events::CodexCollabAgentToolCallEventRequest; +use crate::events::CodexCommandExecutionEventParams; +use crate::events::CodexCommandExecutionEventRequest; use crate::events::CodexCompactionEventRequest; +use crate::events::CodexDynamicToolCallEventParams; +use crate::events::CodexDynamicToolCallEventRequest; +use crate::events::CodexFileChangeEventParams; +use crate::events::CodexFileChangeEventRequest; use crate::events::CodexHookRunEventRequest; +use crate::events::CodexImageGenerationEventParams; +use crate::events::CodexImageGenerationEventRequest; +use crate::events::CodexMcpToolCallEventParams; +use crate::events::CodexMcpToolCallEventRequest; use crate::events::CodexPluginEventRequest; use crate::events::CodexPluginUsedEventRequest; +use crate::events::CodexReviewEventParams; +use crate::events::CodexReviewEventRequest; use crate::events::CodexRuntimeMetadata; +use crate::events::CodexToolItemEventBase; use crate::events::CodexTurnEventParams; use crate::events::CodexTurnEventRequest; use crate::events::CodexTurnSteerEventParams; use crate::events::CodexTurnSteerEventRequest; +use crate::events::CodexWebSearchEventParams; +use crate::events::CodexWebSearchEventRequest; +use crate::events::FinalApprovalOutcome; use crate::events::GuardianReviewEventParams; use crate::events::GuardianReviewEventPayload; use crate::events::GuardianReviewEventRequest; +use crate::events::ReviewResolution; +use crate::events::ReviewStatus; +use crate::events::ReviewSubjectKind; +use crate::events::ReviewTrigger; +use crate::events::Reviewer; use crate::events::SkillInvocationEventParams; use crate::events::SkillInvocationEventRequest; use crate::events::ThreadInitializedEvent; use crate::events::ThreadInitializedEventParams; +use crate::events::ToolItemFailureKind; +use crate::events::ToolItemTerminalStatus; use crate::events::TrackEventRequest; +use crate::events::WebSearchActionKind; use crate::events::codex_app_metadata; use crate::events::codex_compaction_event_params; use crate::events::codex_hook_run_metadata; @@ -47,14 +77,38 @@ use crate::facts::TurnSteerRejectionReason; use crate::facts::TurnSteerResult; use crate::facts::TurnTokenUsageFact; use crate::now_unix_seconds; +use crate::option_i64_to_u64; +use crate::serialize_enum_as_string; +use crate::usize_to_u64; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponse; use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::CollabAgentStatus; +use codex_app_server_protocol::CollabAgentTool; +use codex_app_server_protocol::CollabAgentToolCallStatus; +use codex_app_server_protocol::CommandAction; +use codex_app_server_protocol::CommandExecutionApprovalDecision; +use codex_app_server_protocol::CommandExecutionSource; +use codex_app_server_protocol::CommandExecutionStatus; +use codex_app_server_protocol::DynamicToolCallOutputContentItem; +use codex_app_server_protocol::DynamicToolCallStatus; +use codex_app_server_protocol::FileChangeApprovalDecision; +use codex_app_server_protocol::GuardianApprovalReviewAction; +use codex_app_server_protocol::GuardianApprovalReviewStatus; use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::McpToolCallStatus; +use codex_app_server_protocol::NetworkPolicyRuleAction; +use codex_app_server_protocol::PatchApplyStatus; +use codex_app_server_protocol::PatchChangeKind; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::RequestPermissionProfile; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ServerResponse; +use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::UserInput; +use codex_app_server_protocol::WebSearchAction; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; use codex_login::default_client::originator; @@ -64,10 +118,14 @@ use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsage; +use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionsResponse as CoreRequestPermissionsResponse; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::path::PathBuf; #[derive(Default)] pub(crate) struct AnalyticsReducer { @@ -75,6 +133,9 @@ pub(crate) struct AnalyticsReducer { turns: HashMap, connections: HashMap, threads: HashMap, + tool_items_started_at_ms: HashMap, + pending_reviews: HashMap, + item_review_summaries: HashMap, } struct ConnectionState { @@ -108,6 +169,16 @@ impl<'a> AnalyticsDropSite<'a> { } } + fn review(input: &'a PendingReviewState) -> Self { + Self { + event_name: "review", + thread_id: &input.thread_id, + turn_id: Some(&input.turn_id), + review_id: Some(&input.review_id), + item_id: input.item_id.as_deref(), + } + } + fn compaction(input: &'a CodexCompactionEvent) -> Self { Self { event_name: "compaction", @@ -118,6 +189,19 @@ impl<'a> AnalyticsDropSite<'a> { } } + fn tool_item( + notification: &'a codex_app_server_protocol::ItemCompletedNotification, + item_id: &'a str, + ) -> Self { + Self { + event_name: "tool item", + thread_id: ¬ification.thread_id, + turn_id: Some(¬ification.turn_id), + review_id: None, + item_id: Some(item_id), + } + } + fn turn_steer(thread_id: &'a str) -> Self { Self { event_name: "turn steer", @@ -145,9 +229,33 @@ enum MissingAnalyticsContext { ThreadMetadata, } +#[derive(Clone)] +struct PendingReviewState { + thread_id: String, + turn_id: String, + item_id: Option, + review_id: String, + subject_kind: ReviewSubjectKind, + subject_name: String, + trigger: ReviewTrigger, + started_at_ms: u64, + requested_additional_permissions: bool, + requested_network_access: bool, +} + +#[derive(Clone, Default)] +struct ItemReviewSummary { + review_count: u64, + guardian_review_count: u64, + user_review_count: u64, + final_approval_outcome: Option, + requested_additional_permissions: bool, + requested_network_access: bool, +} + #[derive(Clone)] struct ThreadMetadataState { - thread_source: Option<&'static str>, + thread_source: Option, initialization_mode: ThreadInitializationMode, subagent_source: Option, parent_thread_id: Option, @@ -156,6 +264,7 @@ struct ThreadMetadataState { impl ThreadMetadataState { fn from_thread_metadata( session_source: &SessionSource, + thread_source: Option, initialization_mode: ThreadInitializationMode, ) -> Self { let (subagent_source, parent_thread_id) = match session_source { @@ -172,7 +281,7 @@ impl ThreadMetadataState { | SessionSource::Unknown => (None, None), }; Self { - thread_source: session_source.thread_source_name(), + thread_source, initialization_mode, subagent_source, parent_thread_id, @@ -213,7 +322,52 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + latest_diff: Option, steer_count: usize, + tool_counts: TurnToolCounts, +} + +#[derive(Hash, Eq, PartialEq)] +struct ToolItemKey { + thread_id: String, + turn_id: String, + item_id: String, +} + +#[derive(Default)] +struct TurnToolCounts { + total: usize, + shell_command: usize, + file_change: usize, + mcp_tool_call: usize, + dynamic_tool_call: usize, + subagent_tool_call: usize, + web_search: usize, + image_generation: usize, +} + +impl TurnToolCounts { + fn record(&mut self, item: &ThreadItem) { + match item { + ThreadItem::CommandExecution { .. } => self.shell_command += 1, + ThreadItem::FileChange { .. } => self.file_change += 1, + ThreadItem::McpToolCall { .. } => self.mcp_tool_call += 1, + ThreadItem::DynamicToolCall { .. } => self.dynamic_tool_call += 1, + ThreadItem::CollabAgentToolCall { .. } => self.subagent_tool_call += 1, + ThreadItem::WebSearch { .. } => self.web_search += 1, + ThreadItem::ImageGeneration { .. } => self.image_generation += 1, + ThreadItem::UserMessage { .. } + | ThreadItem::HookPrompt { .. } + | ThreadItem::AgentMessage { .. } + | ThreadItem::Plan { .. } + | ThreadItem::Reasoning { .. } + | ThreadItem::ImageView { .. } + | ThreadItem::EnteredReviewMode { .. } + | ThreadItem::ExitedReviewMode { .. } + | ThreadItem::ContextCompaction { .. } => return, + } + self.total += 1; + } } impl AnalyticsReducer { @@ -247,7 +401,7 @@ impl AnalyticsReducer { response, } => { if let Some(response) = response.into_client_response(request_id) { - self.ingest_response(connection_id, response, out); + self.ingest_response(connection_id, response, out).await; } } AnalyticsFact::ErrorResponse { @@ -259,15 +413,38 @@ impl AnalyticsReducer { self.ingest_error_response(connection_id, request_id, error_type, out); } AnalyticsFact::Notification(notification) => { - self.ingest_notification(*notification, out); + self.ingest_notification(*notification, out).await; } AnalyticsFact::ServerRequest { - connection_id: _connection_id, - request: _request, - } => {} + connection_id, + request, + } => { + self.ingest_server_request(connection_id, *request); + } AnalyticsFact::ServerResponse { - response: _response, - } => {} + completed_at_ms, + response, + } => { + self.ingest_server_response(completed_at_ms, *response, out); + } + AnalyticsFact::EffectivePermissionsApprovalResponse { + completed_at_ms, + request_id, + response, + } => { + self.ingest_effective_permissions_approval_response( + completed_at_ms, + request_id, + *response, + out, + ); + } + AnalyticsFact::ServerRequestAborted { + completed_at_ms, + request_id, + } => { + self.ingest_server_request_aborted(completed_at_ms, request_id, out); + } AnalyticsFact::Custom(input) => match input { CustomAnalyticsFact::SubAgentThreadStarted(input) => { self.ingest_subagent_thread_started(input, out); @@ -279,10 +456,10 @@ impl AnalyticsReducer { self.ingest_guardian_review(*input, out); } CustomAnalyticsFact::TurnResolvedConfig(input) => { - self.ingest_turn_resolved_config(*input, out); + self.ingest_turn_resolved_config(*input, out).await; } CustomAnalyticsFact::TurnTokenUsage(input) => { - self.ingest_turn_token_usage(*input, out); + self.ingest_turn_token_usage(*input, out).await; } CustomAnalyticsFact::SkillInvoked(input) => { self.ingest_skill_invoked(input, out).await; @@ -348,7 +525,7 @@ impl AnalyticsReducer { thread_state .metadata .get_or_insert_with(|| ThreadMetadataState { - thread_source: Some("subagent"), + thread_source: Some(ThreadSource::Subagent), initialization_mode: ThreadInitializationMode::New, subagent_source: Some(subagent_source_name(&input.subagent_source)), parent_thread_id, @@ -414,7 +591,7 @@ impl AnalyticsReducer { } } - fn ingest_turn_resolved_config( + async fn ingest_turn_resolved_config( &mut self, input: TurnResolvedConfigFact, out: &mut Vec, @@ -430,15 +607,17 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + latest_diff: None, steer_count: 0, + tool_counts: TurnToolCounts::default(), }); turn_state.thread_id = Some(thread_id); turn_state.num_input_images = Some(num_input_images); turn_state.resolved_config = Some(input); - self.maybe_emit_turn_event(&turn_id, out); + self.maybe_emit_turn_event(&turn_id, out).await; } - fn ingest_turn_token_usage( + async fn ingest_turn_token_usage( &mut self, input: TurnTokenUsageFact, out: &mut Vec, @@ -452,11 +631,13 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + latest_diff: None, steer_count: 0, + tool_counts: TurnToolCounts::default(), }); turn_state.thread_id = Some(input.thread_id); turn_state.token_usage = Some(input.token_usage); - self.maybe_emit_turn_event(&turn_id, out); + self.maybe_emit_turn_event(&turn_id, out).await; } async fn ingest_skill_invoked( @@ -496,11 +677,13 @@ impl AnalyticsReducer { skill_name: invocation.skill_name.clone(), event_params: SkillInvocationEventParams { thread_id: Some(tracking.thread_id.clone()), + turn_id: Some(tracking.turn_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()), + plugin_id: invocation.plugin_id, }, }, )); @@ -561,7 +744,7 @@ impl AnalyticsReducer { }); } - fn ingest_response( + async fn ingest_response( &mut self, connection_id: u64, response: ClientResponse, @@ -613,12 +796,14 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + latest_diff: None, steer_count: 0, + tool_counts: TurnToolCounts::default(), }); turn_state.connection_id = Some(connection_id); turn_state.thread_id = Some(pending_request.thread_id); turn_state.num_input_images = Some(pending_request.num_input_images); - self.maybe_emit_turn_event(&turn_id, out); + self.maybe_emit_turn_event(&turn_id, out).await; } ClientResponse::TurnSteer { request_id, @@ -630,6 +815,207 @@ impl AnalyticsReducer { } } + fn ingest_server_request(&mut self, _connection_id: u64, request: ServerRequest) { + match request { + ServerRequest::CommandExecutionRequestApproval { request_id, params } => { + let is_network_access_review = params.network_approval_context.is_some(); + let requested_network_access = is_network_access_review + || params + .proposed_network_policy_amendments + .as_ref() + .is_some_and(|amendments| !amendments.is_empty()) + || params + .additional_permissions + .as_ref() + .and_then(|permissions| permissions.network.as_ref()) + .and_then(|network| network.enabled) + .unwrap_or(false); + let requested_additional_permissions = params.additional_permissions.is_some(); + let trigger = if params.approval_id.is_some() { + ReviewTrigger::ExecveIntercept + } else if requested_network_access { + ReviewTrigger::NetworkPolicyDenial + } else if requested_additional_permissions { + ReviewTrigger::SandboxDenial + } else { + ReviewTrigger::Initial + }; + let Some(started_at_ms) = option_i64_to_u64(Some(params.started_at_ms)) else { + return; + }; + self.pending_reviews.insert( + request_id.clone(), + PendingReviewState { + thread_id: params.thread_id, + turn_id: params.turn_id, + item_id: Some(params.item_id), + review_id: user_review_id(&request_id), + subject_kind: if is_network_access_review { + ReviewSubjectKind::NetworkAccess + } else { + ReviewSubjectKind::CommandExecution + }, + subject_name: if is_network_access_review { + "network_access".to_string() + } else { + "command_execution".to_string() + }, + trigger, + started_at_ms, + requested_additional_permissions, + requested_network_access, + }, + ); + } + ServerRequest::FileChangeRequestApproval { request_id, params } => { + let requested_additional_permissions = params.grant_root.is_some(); + let Some(started_at_ms) = option_i64_to_u64(Some(params.started_at_ms)) else { + return; + }; + self.pending_reviews.insert( + request_id.clone(), + PendingReviewState { + thread_id: params.thread_id, + turn_id: params.turn_id, + item_id: Some(params.item_id), + review_id: user_review_id(&request_id), + subject_kind: ReviewSubjectKind::FileChange, + subject_name: "apply_patch".to_string(), + trigger: if requested_additional_permissions { + ReviewTrigger::SandboxDenial + } else { + ReviewTrigger::Initial + }, + started_at_ms, + requested_additional_permissions, + requested_network_access: false, + }, + ); + } + ServerRequest::PermissionsRequestApproval { request_id, params } => { + let requested_network_access = params + .permissions + .network + .as_ref() + .and_then(|network| network.enabled) + .unwrap_or(false); + let requested_additional_permissions = + requested_network_access || params.permissions.file_system.is_some(); + let trigger = if requested_network_access { + ReviewTrigger::NetworkPolicyDenial + } else if requested_additional_permissions { + ReviewTrigger::SandboxDenial + } else { + ReviewTrigger::Initial + }; + let Some(started_at_ms) = option_i64_to_u64(Some(params.started_at_ms)) else { + return; + }; + self.pending_reviews.insert( + request_id.clone(), + PendingReviewState { + thread_id: params.thread_id, + turn_id: params.turn_id, + item_id: Some(params.item_id), + review_id: user_review_id(&request_id), + subject_kind: ReviewSubjectKind::Permissions, + subject_name: "permissions".to_string(), + trigger, + started_at_ms, + requested_additional_permissions, + requested_network_access, + }, + ); + } + _ => {} + } + } + + fn ingest_server_response( + &mut self, + completed_at_ms: u64, + response: ServerResponse, + out: &mut Vec, + ) { + match response { + ServerResponse::CommandExecutionRequestApproval { + request_id, + response, + } => { + let Some(pending_review) = self.pending_reviews.remove(&request_id) else { + return; + }; + let (status, resolution) = command_execution_review_result(response.decision); + self.emit_review_event( + pending_review, + Reviewer::User, + status, + resolution, + completed_at_ms, + out, + ); + } + ServerResponse::FileChangeRequestApproval { + request_id, + response, + } => { + let Some(pending_review) = self.pending_reviews.remove(&request_id) else { + return; + }; + let (status, resolution) = file_change_review_result(response.decision); + self.emit_review_event( + pending_review, + Reviewer::User, + status, + resolution, + completed_at_ms, + out, + ); + } + _ => {} + } + } + + fn ingest_effective_permissions_approval_response( + &mut self, + completed_at_ms: u64, + request_id: RequestId, + response: CoreRequestPermissionsResponse, + out: &mut Vec, + ) { + let Some(pending_review) = self.pending_reviews.remove(&request_id) else { + return; + }; + let (status, resolution) = effective_permissions_review_result(&response); + self.emit_review_event( + pending_review, + Reviewer::User, + status, + resolution, + completed_at_ms, + out, + ); + } + + fn ingest_server_request_aborted( + &mut self, + completed_at_ms: u64, + request_id: RequestId, + out: &mut Vec, + ) { + let Some(pending_review) = self.pending_reviews.remove(&request_id) else { + return; + }; + self.emit_review_event( + pending_review, + Reviewer::User, + ReviewStatus::Aborted, + ReviewResolution::None, + completed_at_ms, + out, + ); + } + fn ingest_error_response( &mut self, connection_id: u64, @@ -680,12 +1066,86 @@ impl AnalyticsReducer { ); } - fn ingest_notification( + async fn ingest_notification( &mut self, notification: ServerNotification, out: &mut Vec, ) { match notification { + ServerNotification::ItemStarted(notification) => { + let Some(item_id) = tracked_tool_item_id(¬ification.item) else { + return; + }; + let Some(started_at_ms) = option_i64_to_u64(Some(notification.started_at_ms)) + else { + return; + }; + self.tool_items_started_at_ms.insert( + ToolItemKey { + thread_id: notification.thread_id, + turn_id: notification.turn_id, + item_id: item_id.to_string(), + }, + started_at_ms, + ); + } + ServerNotification::ItemCompleted(notification) => { + let Some(item_id) = tracked_tool_item_id(¬ification.item) else { + return; + }; + let Some(turn_state) = self.turns.get_mut(¬ification.turn_id) else { + tracing::warn!( + thread_id = %notification.thread_id, + turn_id = %notification.turn_id, + item_id, + "dropping turn tool count update: missing turn state" + ); + return; + }; + turn_state.tool_counts.record(¬ification.item); + let key = ToolItemKey { + thread_id: notification.thread_id.clone(), + turn_id: notification.turn_id.clone(), + item_id: item_id.to_string(), + }; + let Some(started_at_ms) = self.tool_items_started_at_ms.remove(&key) else { + tracing::warn!( + thread_id = %notification.thread_id, + turn_id = %notification.turn_id, + item_id, + "dropping tool item analytics event: missing item started notification" + ); + return; + }; + let Some(completed_at_ms) = option_i64_to_u64(Some(notification.completed_at_ms)) + else { + return; + }; + let Some((connection_state, thread_metadata)) = self + .thread_context_or_warn(AnalyticsDropSite::tool_item(¬ification, item_id)) + else { + return; + }; + if let Some(event) = tool_item_event(ToolItemEventInput { + thread_id: ¬ification.thread_id, + turn_id: ¬ification.turn_id, + item: ¬ification.item, + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + review_summary: self.item_review_summaries.get(&key), + }) { + out.push(event); + } + self.item_review_summaries.remove(&key); + } + ServerNotification::ItemGuardianApprovalReviewStarted(notification) => { + let _ = notification; + } + ServerNotification::ItemGuardianApprovalReviewCompleted(notification) => { + self.ingest_guardian_review_completed(notification, out); + } ServerNotification::TurnStarted(notification) => { let turn_state = self.turns.entry(notification.turn.id).or_insert(TurnState { connection_id: None, @@ -695,13 +1155,34 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + latest_diff: None, steer_count: 0, + tool_counts: TurnToolCounts::default(), }); turn_state.started_at = notification .turn .started_at .and_then(|started_at| u64::try_from(started_at).ok()); } + ServerNotification::TurnDiffUpdated(notification) => { + let turn_state = + self.turns + .entry(notification.turn_id.clone()) + .or_insert(TurnState { + connection_id: None, + thread_id: None, + num_input_images: None, + resolved_config: None, + started_at: None, + token_usage: None, + completed: None, + latest_diff: None, + steer_count: 0, + tool_counts: TurnToolCounts::default(), + }); + turn_state.thread_id = Some(notification.thread_id); + turn_state.latest_diff = Some(notification.diff); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -714,7 +1195,9 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + latest_diff: None, steer_count: 0, + tool_counts: TurnToolCounts::default(), }); turn_state.completed = Some(CompletedTurnState { status: analytics_turn_status(notification.turn.status), @@ -733,7 +1216,7 @@ impl AnalyticsReducer { .and_then(|duration_ms| u64::try_from(duration_ms).ok()), }); let turn_id = notification.turn.id; - self.maybe_emit_turn_event(&turn_id, out); + self.maybe_emit_turn_event(&turn_id, out).await; } _ => {} } @@ -747,13 +1230,16 @@ impl AnalyticsReducer { initialization_mode: ThreadInitializationMode, out: &mut Vec, ) { - let thread_source: SessionSource = thread.source.into(); + let session_source: SessionSource = thread.source.into(); let thread_id = thread.id; let Some(connection_state) = self.connections.get(&connection_id) else { return; }; - let thread_metadata = - ThreadMetadataState::from_thread_metadata(&thread_source, initialization_mode); + let thread_metadata = ThreadMetadataState::from_thread_metadata( + &session_source, + thread.thread_source.map(Into::into), + initialization_mode, + ); self.threads.insert( thread_id.clone(), ThreadAnalyticsState { @@ -772,7 +1258,7 @@ impl AnalyticsReducer { ephemeral: thread.ephemeral, thread_source: thread_metadata.thread_source, initialization_mode, - subagent_source: thread_metadata.subagent_source, + subagent_source: thread_metadata.subagent_source.clone(), parent_thread_id: thread_metadata.parent_thread_id, created_at: u64::try_from(thread.created_at).unwrap_or_default(), }, @@ -801,6 +1287,48 @@ impl AnalyticsReducer { ))); } + fn ingest_guardian_review_completed( + &mut self, + notification: codex_app_server_protocol::ItemGuardianApprovalReviewCompletedNotification, + out: &mut Vec, + ) { + let Some((status, resolution)) = guardian_review_result(notification.review.status) else { + return; + }; + let (subject_kind, subject_name, trigger) = + guardian_review_subject_metadata(¬ification.action); + let Some(started_at_ms) = option_i64_to_u64(Some(notification.started_at_ms)) else { + return; + }; + let pending_review = PendingReviewState { + thread_id: notification.thread_id, + turn_id: notification.turn_id, + item_id: notification.target_item_id, + review_id: notification.review_id, + subject_kind, + subject_name, + trigger, + started_at_ms, + requested_additional_permissions: guardian_review_requested_additional_permissions( + ¬ification.action, + ), + requested_network_access: guardian_review_requested_network_access( + ¬ification.action, + ), + }; + let Some(completed_at_ms) = option_i64_to_u64(Some(notification.completed_at_ms)) else { + return; + }; + self.emit_review_event( + pending_review, + Reviewer::Guardian, + status, + resolution, + completed_at_ms, + out, + ); + } + fn ingest_turn_steer_response( &mut self, connection_id: u64, @@ -855,7 +1383,7 @@ impl AnalyticsReducer { accepted_turn_id, app_server_client: connection_state.app_server_client.clone(), runtime: connection_state.runtime.clone(), - thread_source: thread_metadata.thread_source.map(str::to_string), + thread_source: thread_metadata.thread_source, subagent_source: thread_metadata.subagent_source.clone(), parent_thread_id: thread_metadata.parent_thread_id.clone(), num_input_images: pending_request.num_input_images, @@ -866,7 +1394,74 @@ impl AnalyticsReducer { })); } - fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + fn emit_review_event( + &mut self, + pending_review: PendingReviewState, + reviewer: Reviewer, + status: ReviewStatus, + resolution: ReviewResolution, + completed_at_ms: u64, + out: &mut Vec, + ) { + if let Some(item_key) = item_review_summary_key(&pending_review) { + self.record_item_review_summary( + item_key, + reviewer, + status, + resolution, + &pending_review, + ); + } + let Some((connection_state, thread_metadata)) = + self.thread_context_or_warn(AnalyticsDropSite::review(&pending_review)) + else { + return; + }; + out.push(TrackEventRequest::ReviewEvent(CodexReviewEventRequest { + event_type: "codex_review_event", + event_params: CodexReviewEventParams { + thread_id: pending_review.thread_id, + turn_id: pending_review.turn_id, + item_id: pending_review.item_id, + review_id: pending_review.review_id, + app_server_client: connection_state.app_server_client.clone(), + runtime: connection_state.runtime.clone(), + thread_source: thread_metadata.thread_source, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), + subject_kind: pending_review.subject_kind, + subject_name: pending_review.subject_name, + reviewer, + trigger: pending_review.trigger, + status, + resolution, + started_at_ms: pending_review.started_at_ms, + completed_at_ms, + duration_ms: observed_duration_ms(pending_review.started_at_ms, completed_at_ms), + }, + })); + } + + fn record_item_review_summary( + &mut self, + item_key: ToolItemKey, + reviewer: Reviewer, + status: ReviewStatus, + resolution: ReviewResolution, + pending_review: &PendingReviewState, + ) { + let summary = self.item_review_summaries.entry(item_key).or_default(); + summary.review_count += 1; + match reviewer { + Reviewer::Guardian => summary.guardian_review_count += 1, + Reviewer::User => summary.user_review_count += 1, + } + summary.final_approval_outcome = Some(final_approval_outcome(reviewer, status, resolution)); + summary.requested_additional_permissions |= pending_review.requested_additional_permissions; + summary.requested_network_access |= pending_review.requested_network_access; + } + + async fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { let Some(turn_state) = self.turns.get(turn_id) else { return; }; @@ -899,18 +1494,23 @@ impl AnalyticsReducer { warn_missing_analytics_context(&drop_site, MissingAnalyticsContext::ThreadMetadata); return; }; - out.push(TrackEventRequest::TurnEvent(Box::new( - CodexTurnEventRequest { - event_type: "codex_turn_event", - event_params: codex_turn_event_params( - connection_state.app_server_client.clone(), - connection_state.runtime.clone(), - turn_id.to_string(), - turn_state, - thread_metadata, - ), - }, - ))); + let turn_event = TrackEventRequest::TurnEvent(Box::new(CodexTurnEventRequest { + event_type: "codex_turn_event", + event_params: codex_turn_event_params( + connection_state.app_server_client.clone(), + connection_state.runtime.clone(), + turn_id.to_string(), + turn_state, + thread_metadata, + ), + })); + let accepted_line_event = accepted_line_event_input(turn_id, turn_state); + + out.push(turn_event); + if let Some((mut input, cwd)) = accepted_line_event { + input.repo_hash = accepted_line_repo_hash_for_cwd(cwd.as_path()).await; + out.extend(accepted_line_fingerprint_event_requests(input)); + } self.turns.remove(turn_id); } @@ -976,6 +1576,837 @@ fn warn_missing_analytics_context( ); } +fn tracked_tool_item_id(item: &ThreadItem) -> Option<&str> { + match item { + ThreadItem::CommandExecution { id, .. } + | ThreadItem::FileChange { id, .. } + | ThreadItem::McpToolCall { id, .. } + | ThreadItem::DynamicToolCall { id, .. } + | ThreadItem::CollabAgentToolCall { id, .. } + | ThreadItem::WebSearch { id, .. } + | ThreadItem::ImageGeneration { id, .. } => Some(id), + ThreadItem::UserMessage { .. } + | ThreadItem::HookPrompt { .. } + | ThreadItem::AgentMessage { .. } + | ThreadItem::Plan { .. } + | ThreadItem::Reasoning { .. } + | ThreadItem::ImageView { .. } + | ThreadItem::EnteredReviewMode { .. } + | ThreadItem::ExitedReviewMode { .. } + | ThreadItem::ContextCompaction { .. } => None, + } +} + +fn item_review_summary_key(pending_review: &PendingReviewState) -> Option { + match pending_review.subject_kind { + ReviewSubjectKind::CommandExecution + | ReviewSubjectKind::FileChange + | ReviewSubjectKind::McpToolCall => Some(ToolItemKey { + thread_id: pending_review.thread_id.clone(), + turn_id: pending_review.turn_id.clone(), + item_id: pending_review.item_id.clone()?, + }), + ReviewSubjectKind::Permissions | ReviewSubjectKind::NetworkAccess => None, + } +} + +struct ToolItemEventInput<'a> { + thread_id: &'a str, + turn_id: &'a str, + item: &'a ThreadItem, + started_at_ms: u64, + completed_at_ms: u64, + connection_state: &'a ConnectionState, + thread_metadata: &'a ThreadMetadataState, + review_summary: Option<&'a ItemReviewSummary>, +} + +fn tool_item_event(input: ToolItemEventInput<'_>) -> Option { + let ToolItemEventInput { + thread_id, + turn_id, + item, + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + review_summary, + } = input; + match item { + ThreadItem::CommandExecution { + id, + source, + status, + command_actions, + exit_code, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = command_execution_outcome(status)?; + let action_counts = command_action_counts(command_actions); + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + command_execution_tool_name(*source).to_string(), + ToolItemOutcome { + terminal_status, + failure_kind, + execution_duration_ms: option_i64_to_u64(*duration_ms), + }, + ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + review_summary, + }, + ); + Some(TrackEventRequest::CommandExecution( + CodexCommandExecutionEventRequest { + event_type: "codex_command_execution_event", + event_params: CodexCommandExecutionEventParams { + base, + command_execution_source: *source, + exit_code: *exit_code, + command_total_action_count: action_counts.total, + command_read_action_count: action_counts.read, + command_list_files_action_count: action_counts.list_files, + command_search_action_count: action_counts.search, + command_unknown_action_count: action_counts.unknown, + }, + }, + )) + } + ThreadItem::FileChange { + id, + changes, + status, + } => { + let (terminal_status, failure_kind) = patch_apply_outcome(status)?; + let counts = file_change_counts(changes); + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + "apply_patch".to_string(), + ToolItemOutcome { + terminal_status, + failure_kind, + execution_duration_ms: None, + }, + ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + review_summary, + }, + ); + Some(TrackEventRequest::FileChange(CodexFileChangeEventRequest { + event_type: "codex_file_change_event", + event_params: CodexFileChangeEventParams { + base, + file_change_count: usize_to_u64(changes.len()), + file_add_count: counts.add, + file_update_count: counts.update, + file_delete_count: counts.delete, + file_move_count: counts.move_, + }, + })) + } + ThreadItem::McpToolCall { + id, + server, + tool, + status, + error, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = mcp_tool_call_outcome(status)?; + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + tool.clone(), + ToolItemOutcome { + terminal_status, + failure_kind, + execution_duration_ms: option_i64_to_u64(*duration_ms), + }, + ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + review_summary, + }, + ); + Some(TrackEventRequest::McpToolCall( + CodexMcpToolCallEventRequest { + event_type: "codex_mcp_tool_call_event", + event_params: CodexMcpToolCallEventParams { + base, + mcp_server_name: server.clone(), + mcp_tool_name: tool.clone(), + mcp_error_present: error.is_some(), + }, + }, + )) + } + ThreadItem::DynamicToolCall { + id, + tool, + status, + content_items, + success, + duration_ms, + .. + } => { + let (terminal_status, failure_kind) = dynamic_tool_call_outcome(status)?; + let counts = content_items + .as_ref() + .map(|items| dynamic_content_counts(items)); + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + tool.clone(), + ToolItemOutcome { + terminal_status, + failure_kind, + execution_duration_ms: option_i64_to_u64(*duration_ms), + }, + ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + review_summary, + }, + ); + Some(TrackEventRequest::DynamicToolCall( + CodexDynamicToolCallEventRequest { + event_type: "codex_dynamic_tool_call_event", + event_params: CodexDynamicToolCallEventParams { + base, + dynamic_tool_name: tool.clone(), + success: *success, + output_content_item_count: counts.map(|counts| counts.total), + output_text_item_count: counts.map(|counts| counts.text), + output_image_item_count: counts.map(|counts| counts.image), + }, + }, + )) + } + ThreadItem::CollabAgentToolCall { + id, + tool, + status, + sender_thread_id, + receiver_thread_ids, + model, + reasoning_effort, + agents_states, + .. + } => { + let (terminal_status, failure_kind) = collab_tool_call_outcome(status)?; + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + collab_agent_tool_name(tool).to_string(), + ToolItemOutcome { + terminal_status, + failure_kind, + execution_duration_ms: None, + }, + ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + review_summary, + }, + ); + Some(TrackEventRequest::CollabAgentToolCall( + CodexCollabAgentToolCallEventRequest { + event_type: "codex_collab_agent_tool_call_event", + event_params: CodexCollabAgentToolCallEventParams { + base, + sender_thread_id: sender_thread_id.clone(), + receiver_thread_count: usize_to_u64(receiver_thread_ids.len()), + receiver_thread_ids: Some(receiver_thread_ids.clone()), + requested_model: model.clone(), + requested_reasoning_effort: reasoning_effort + .as_ref() + .and_then(serialize_enum_as_string), + agent_state_count: Some(usize_to_u64(agents_states.len())), + completed_agent_count: Some(usize_to_u64( + agents_states + .values() + .filter(|state| state.status == CollabAgentStatus::Completed) + .count(), + )), + failed_agent_count: Some(usize_to_u64( + agents_states + .values() + .filter(|state| { + matches!( + state.status, + CollabAgentStatus::Errored + | CollabAgentStatus::Shutdown + | CollabAgentStatus::NotFound + ) + }) + .count(), + )), + }, + }, + )) + } + ThreadItem::WebSearch { id, query, action } => { + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + "web_search".to_string(), + ToolItemOutcome { + terminal_status: ToolItemTerminalStatus::Completed, + failure_kind: None, + execution_duration_ms: None, + }, + ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + review_summary, + }, + ); + Some(TrackEventRequest::WebSearch(CodexWebSearchEventRequest { + event_type: "codex_web_search_event", + event_params: CodexWebSearchEventParams { + base, + web_search_action: action.as_ref().map(web_search_action_kind), + query_present: !query.trim().is_empty(), + query_count: web_search_query_count(query, action.as_ref()), + }, + })) + } + ThreadItem::ImageGeneration { + id, + status, + revised_prompt, + saved_path, + .. + } => { + let (terminal_status, failure_kind) = image_generation_outcome(status.as_str()); + let base = tool_item_base( + thread_id, + turn_id, + id.clone(), + "image_generation".to_string(), + ToolItemOutcome { + terminal_status, + failure_kind, + execution_duration_ms: None, + }, + ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + review_summary, + }, + ); + Some(TrackEventRequest::ImageGeneration( + CodexImageGenerationEventRequest { + event_type: "codex_image_generation_event", + event_params: CodexImageGenerationEventParams { + base, + revised_prompt_present: revised_prompt.is_some(), + saved_path_present: saved_path.is_some(), + }, + }, + )) + } + _ => None, + } +} + +struct ToolItemOutcome { + terminal_status: ToolItemTerminalStatus, + failure_kind: Option, + execution_duration_ms: Option, +} + +#[derive(Default)] +struct CommandActionCounts { + total: u64, + read: u64, + list_files: u64, + search: u64, + unknown: u64, +} + +fn command_action_counts(command_actions: &[CommandAction]) -> CommandActionCounts { + let mut counts = CommandActionCounts { + total: usize_to_u64(command_actions.len()), + ..Default::default() + }; + for action in command_actions { + match action { + CommandAction::Read { .. } => counts.read += 1, + CommandAction::ListFiles { .. } => counts.list_files += 1, + CommandAction::Search { .. } => counts.search += 1, + CommandAction::Unknown { .. } => counts.unknown += 1, + } + } + counts +} + +#[derive(Clone, Copy)] +struct ToolItemContext<'a> { + started_at_ms: u64, + completed_at_ms: u64, + connection_state: &'a ConnectionState, + thread_metadata: &'a ThreadMetadataState, + review_summary: Option<&'a ItemReviewSummary>, +} + +fn tool_item_base( + thread_id: &str, + turn_id: &str, + item_id: String, + tool_name: String, + outcome: ToolItemOutcome, + context: ToolItemContext<'_>, +) -> CodexToolItemEventBase { + let thread_metadata = context.thread_metadata; + let review_summary = context.review_summary.cloned().unwrap_or_default(); + CodexToolItemEventBase { + thread_id: thread_id.to_string(), + turn_id: turn_id.to_string(), + item_id, + app_server_client: context.connection_state.app_server_client.clone(), + runtime: context.connection_state.runtime.clone(), + thread_source: thread_metadata.thread_source, + subagent_source: thread_metadata.subagent_source.clone(), + parent_thread_id: thread_metadata.parent_thread_id.clone(), + tool_name, + started_at_ms: context.started_at_ms, + completed_at_ms: context.completed_at_ms, + // duration_ms reflects item lifecycle observed by app-server. For web + // search and image generation in particular, that can be narrower than + // full upstream execution time. + duration_ms: observed_duration_ms(context.started_at_ms, context.completed_at_ms), + execution_duration_ms: outcome.execution_duration_ms, + review_count: review_summary.review_count, + guardian_review_count: review_summary.guardian_review_count, + user_review_count: review_summary.user_review_count, + final_approval_outcome: review_summary + .final_approval_outcome + .unwrap_or(FinalApprovalOutcome::Unknown), + terminal_status: outcome.terminal_status, + failure_kind: outcome.failure_kind, + requested_additional_permissions: review_summary.requested_additional_permissions, + requested_network_access: review_summary.requested_network_access, + } +} + +fn observed_duration_ms(started_at_ms: u64, completed_at_ms: u64) -> Option { + completed_at_ms.checked_sub(started_at_ms) +} + +fn user_review_id(request_id: &RequestId) -> String { + format!("user:{request_id}") +} + +fn command_execution_review_result( + decision: CommandExecutionApprovalDecision, +) -> (ReviewStatus, ReviewResolution) { + match decision { + CommandExecutionApprovalDecision::Accept => { + (ReviewStatus::Approved, ReviewResolution::None) + } + CommandExecutionApprovalDecision::AcceptForSession => { + (ReviewStatus::Approved, ReviewResolution::SessionApproval) + } + CommandExecutionApprovalDecision::AcceptWithExecpolicyAmendment { .. } => ( + ReviewStatus::Approved, + ReviewResolution::ExecPolicyAmendment, + ), + CommandExecutionApprovalDecision::ApplyNetworkPolicyAmendment { + network_policy_amendment, + } => match network_policy_amendment.action { + NetworkPolicyRuleAction::Allow => ( + ReviewStatus::Approved, + ReviewResolution::NetworkPolicyAmendment, + ), + NetworkPolicyRuleAction::Deny => ( + ReviewStatus::Denied, + ReviewResolution::NetworkPolicyAmendment, + ), + }, + CommandExecutionApprovalDecision::Decline => (ReviewStatus::Denied, ReviewResolution::None), + CommandExecutionApprovalDecision::Cancel => (ReviewStatus::Aborted, ReviewResolution::None), + } +} + +fn file_change_review_result( + decision: FileChangeApprovalDecision, +) -> (ReviewStatus, ReviewResolution) { + match decision { + FileChangeApprovalDecision::Accept => (ReviewStatus::Approved, ReviewResolution::None), + FileChangeApprovalDecision::AcceptForSession => { + (ReviewStatus::Approved, ReviewResolution::SessionApproval) + } + FileChangeApprovalDecision::Decline => (ReviewStatus::Denied, ReviewResolution::None), + FileChangeApprovalDecision::Cancel => (ReviewStatus::Aborted, ReviewResolution::None), + } +} + +fn effective_permissions_review_result( + response: &CoreRequestPermissionsResponse, +) -> (ReviewStatus, ReviewResolution) { + if response.permissions.is_empty() { + return (ReviewStatus::Denied, ReviewResolution::None); + } + + match response.scope { + CorePermissionGrantScope::Turn => (ReviewStatus::Approved, ReviewResolution::None), + CorePermissionGrantScope::Session => { + (ReviewStatus::Approved, ReviewResolution::SessionApproval) + } + } +} + +fn guardian_review_result( + status: GuardianApprovalReviewStatus, +) -> Option<(ReviewStatus, ReviewResolution)> { + match status { + GuardianApprovalReviewStatus::InProgress => None, + GuardianApprovalReviewStatus::Approved => { + Some((ReviewStatus::Approved, ReviewResolution::None)) + } + GuardianApprovalReviewStatus::Denied => { + Some((ReviewStatus::Denied, ReviewResolution::None)) + } + GuardianApprovalReviewStatus::TimedOut => { + Some((ReviewStatus::TimedOut, ReviewResolution::None)) + } + GuardianApprovalReviewStatus::Aborted => { + Some((ReviewStatus::Aborted, ReviewResolution::None)) + } + } +} + +fn guardian_review_subject_metadata( + action: &GuardianApprovalReviewAction, +) -> (ReviewSubjectKind, String, ReviewTrigger) { + match action { + GuardianApprovalReviewAction::Command { .. } => ( + ReviewSubjectKind::CommandExecution, + "command_execution".to_string(), + ReviewTrigger::Initial, + ), + GuardianApprovalReviewAction::Execve { .. } => ( + ReviewSubjectKind::CommandExecution, + "command_execution".to_string(), + ReviewTrigger::ExecveIntercept, + ), + GuardianApprovalReviewAction::ApplyPatch { .. } => ( + ReviewSubjectKind::FileChange, + "apply_patch".to_string(), + ReviewTrigger::SandboxDenial, + ), + GuardianApprovalReviewAction::NetworkAccess { .. } => ( + ReviewSubjectKind::NetworkAccess, + "network_access".to_string(), + ReviewTrigger::NetworkPolicyDenial, + ), + GuardianApprovalReviewAction::RequestPermissions { permissions, .. } => { + let requested_network_access = permissions + .network + .as_ref() + .and_then(|network| network.enabled) + .unwrap_or(false); + let trigger = if requested_network_access { + ReviewTrigger::NetworkPolicyDenial + } else if permissions.file_system.is_some() { + ReviewTrigger::SandboxDenial + } else { + ReviewTrigger::Initial + }; + ( + ReviewSubjectKind::Permissions, + "permissions".to_string(), + trigger, + ) + } + GuardianApprovalReviewAction::McpToolCall { tool_name, .. } => ( + ReviewSubjectKind::McpToolCall, + tool_name.clone(), + ReviewTrigger::Initial, + ), + } +} + +fn guardian_review_requested_additional_permissions(action: &GuardianApprovalReviewAction) -> bool { + match action { + GuardianApprovalReviewAction::ApplyPatch { .. } + | GuardianApprovalReviewAction::NetworkAccess { .. } => true, + GuardianApprovalReviewAction::RequestPermissions { permissions, .. } => { + guardian_review_request_permissions_network_enabled(permissions) + || permissions.file_system.is_some() + } + GuardianApprovalReviewAction::Command { .. } + | GuardianApprovalReviewAction::Execve { .. } + | GuardianApprovalReviewAction::McpToolCall { .. } => false, + } +} + +fn guardian_review_requested_network_access(action: &GuardianApprovalReviewAction) -> bool { + match action { + GuardianApprovalReviewAction::NetworkAccess { .. } => true, + GuardianApprovalReviewAction::RequestPermissions { permissions, .. } => { + guardian_review_request_permissions_network_enabled(permissions) + } + GuardianApprovalReviewAction::ApplyPatch { .. } + | GuardianApprovalReviewAction::Command { .. } + | GuardianApprovalReviewAction::Execve { .. } + | GuardianApprovalReviewAction::McpToolCall { .. } => false, + } +} + +fn guardian_review_request_permissions_network_enabled( + permissions: &RequestPermissionProfile, +) -> bool { + permissions + .network + .as_ref() + .and_then(|network| network.enabled) + .unwrap_or(false) +} + +fn final_approval_outcome( + reviewer: Reviewer, + status: ReviewStatus, + resolution: ReviewResolution, +) -> FinalApprovalOutcome { + match (reviewer, status, resolution) { + (Reviewer::Guardian, ReviewStatus::Approved, _) => FinalApprovalOutcome::GuardianApproved, + (Reviewer::Guardian, ReviewStatus::Denied, _) => FinalApprovalOutcome::GuardianDenied, + (Reviewer::Guardian, _, _) => FinalApprovalOutcome::GuardianAborted, + (Reviewer::User, ReviewStatus::Approved, ReviewResolution::SessionApproval) => { + FinalApprovalOutcome::UserApprovedForSession + } + (Reviewer::User, ReviewStatus::Approved, _) => FinalApprovalOutcome::UserApproved, + (Reviewer::User, ReviewStatus::Denied, _) => FinalApprovalOutcome::UserDenied, + (Reviewer::User, _, _) => FinalApprovalOutcome::UserAborted, + } +} + +fn command_execution_tool_name(source: CommandExecutionSource) -> &'static str { + match source { + CommandExecutionSource::UnifiedExecStartup + | CommandExecutionSource::UnifiedExecInteraction => "unified_exec", + CommandExecutionSource::UserShell => "user_shell", + CommandExecutionSource::Agent => "shell", + } +} + +fn command_execution_outcome( + status: &CommandExecutionStatus, +) -> Option<(ToolItemTerminalStatus, Option)> { + match status { + CommandExecutionStatus::InProgress => None, + CommandExecutionStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)), + CommandExecutionStatus::Failed => Some(( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + )), + CommandExecutionStatus::Declined => Some(( + ToolItemTerminalStatus::Rejected, + Some(ToolItemFailureKind::ApprovalDenied), + )), + } +} + +fn patch_apply_outcome( + status: &PatchApplyStatus, +) -> Option<(ToolItemTerminalStatus, Option)> { + match status { + PatchApplyStatus::InProgress => None, + PatchApplyStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)), + PatchApplyStatus::Failed => Some(( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + )), + PatchApplyStatus::Declined => Some(( + ToolItemTerminalStatus::Rejected, + Some(ToolItemFailureKind::ApprovalDenied), + )), + } +} + +fn mcp_tool_call_outcome( + status: &McpToolCallStatus, +) -> Option<(ToolItemTerminalStatus, Option)> { + match status { + McpToolCallStatus::InProgress => None, + McpToolCallStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)), + McpToolCallStatus::Failed => Some(( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + )), + } +} + +fn dynamic_tool_call_outcome( + status: &DynamicToolCallStatus, +) -> Option<(ToolItemTerminalStatus, Option)> { + match status { + DynamicToolCallStatus::InProgress => None, + DynamicToolCallStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)), + DynamicToolCallStatus::Failed => Some(( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + )), + } +} + +fn collab_tool_call_outcome( + status: &CollabAgentToolCallStatus, +) -> Option<(ToolItemTerminalStatus, Option)> { + match status { + CollabAgentToolCallStatus::InProgress => None, + CollabAgentToolCallStatus::Completed => Some((ToolItemTerminalStatus::Completed, None)), + CollabAgentToolCallStatus::Failed => Some(( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + )), + } +} + +fn image_generation_outcome(status: &str) -> (ToolItemTerminalStatus, Option) { + match status { + "failed" | "error" => ( + ToolItemTerminalStatus::Failed, + Some(ToolItemFailureKind::ToolError), + ), + _ => (ToolItemTerminalStatus::Completed, None), + } +} + +fn collab_agent_tool_name(tool: &CollabAgentTool) -> &'static str { + match tool { + CollabAgentTool::SpawnAgent => "spawn_agent", + CollabAgentTool::SendInput => "send_input", + CollabAgentTool::ResumeAgent => "resume_agent", + CollabAgentTool::Wait => "wait_agent", + CollabAgentTool::CloseAgent => "close_agent", + } +} + +#[derive(Default)] +struct FileChangeCounts { + add: u64, + update: u64, + delete: u64, + move_: u64, +} + +fn file_change_counts(changes: &[codex_app_server_protocol::FileUpdateChange]) -> FileChangeCounts { + let mut counts = FileChangeCounts::default(); + for change in changes { + match &change.kind { + PatchChangeKind::Add => counts.add += 1, + PatchChangeKind::Delete => counts.delete += 1, + PatchChangeKind::Update { move_path: Some(_) } => counts.move_ += 1, + PatchChangeKind::Update { move_path: None } => counts.update += 1, + } + } + counts +} + +#[derive(Clone, Copy)] +struct DynamicContentCounts { + total: u64, + text: u64, + image: u64, +} + +fn dynamic_content_counts(items: &[DynamicToolCallOutputContentItem]) -> DynamicContentCounts { + let mut text = 0; + let mut image = 0; + for item in items { + match item { + DynamicToolCallOutputContentItem::InputText { .. } => text += 1, + DynamicToolCallOutputContentItem::InputImage { .. } => image += 1, + } + } + DynamicContentCounts { + total: usize_to_u64(items.len()), + text, + image, + } +} + +fn web_search_action_kind(action: &WebSearchAction) -> WebSearchActionKind { + match action { + WebSearchAction::Search { .. } => WebSearchActionKind::Search, + WebSearchAction::OpenPage { .. } => WebSearchActionKind::OpenPage, + WebSearchAction::FindInPage { .. } => WebSearchActionKind::FindInPage, + WebSearchAction::Other => WebSearchActionKind::Other, + } +} + +fn web_search_query_count(query: &str, action: Option<&WebSearchAction>) -> Option { + match action { + Some(WebSearchAction::Search { query, queries }) => queries + .as_ref() + .map(|queries| usize_to_u64(queries.len())) + .or_else(|| query.as_ref().map(|_| 1)), + Some(WebSearchAction::OpenPage { .. }) + | Some(WebSearchAction::FindInPage { .. }) + | Some(WebSearchAction::Other) => None, + None => (!query.trim().is_empty()).then_some(1), + } +} + +fn accepted_line_event_input( + turn_id: &str, + turn_state: &TurnState, +) -> Option<(AcceptedLineFingerprintEventInput, PathBuf)> { + let latest_diff = turn_state.latest_diff.as_deref()?; + let summary = accepted_line_fingerprints_from_unified_diff(latest_diff); + if summary.accepted_added_lines == 0 && summary.accepted_deleted_lines == 0 { + return None; + } + + let thread_id = turn_state.thread_id.clone()?; + let resolved_config = turn_state.resolved_config.clone()?; + + Some(( + AcceptedLineFingerprintEventInput { + event_type: "codex.accepted_line_fingerprints", + turn_id: turn_id.to_string(), + thread_id, + product_surface: Some("codex".to_string()), + model_slug: Some(resolved_config.model.clone()), + completed_at: now_unix_seconds(), + repo_hash: None, + accepted_added_lines: summary.accepted_added_lines, + accepted_deleted_lines: summary.accepted_deleted_lines, + line_fingerprints: summary.line_fingerprints, + }, + resolved_config.permission_profile_cwd, + )) +} + fn codex_turn_event_params( app_server_client: CodexAppServerClientMetadata, runtime: CodexRuntimeMetadata, @@ -1021,7 +2452,7 @@ fn codex_turn_event_params( runtime, submission_type, ephemeral, - thread_source: thread_metadata.thread_source.map(str::to_string), + thread_source: thread_metadata.thread_source, initialization_mode: thread_metadata.initialization_mode, subagent_source: thread_metadata.subagent_source.clone(), parent_thread_id: thread_metadata.parent_thread_id.clone(), @@ -1046,14 +2477,14 @@ fn codex_turn_event_params( status: completed.status, turn_error: completed.turn_error, steer_count: Some(turn_state.steer_count), - total_tool_call_count: None, - shell_command_count: None, - file_change_count: None, - mcp_tool_call_count: None, - dynamic_tool_call_count: None, - subagent_tool_call_count: None, - web_search_count: None, - image_generation_count: None, + total_tool_call_count: Some(turn_state.tool_counts.total), + shell_command_count: Some(turn_state.tool_counts.shell_command), + file_change_count: Some(turn_state.tool_counts.file_change), + mcp_tool_call_count: Some(turn_state.tool_counts.mcp_tool_call), + dynamic_tool_call_count: Some(turn_state.tool_counts.dynamic_tool_call), + subagent_tool_call_count: Some(turn_state.tool_counts.subagent_tool_call), + web_search_count: Some(turn_state.tool_counts.web_search), + image_generation_count: Some(turn_state.tool_counts.image_generation), input_tokens: token_usage .as_ref() .map(|token_usage| token_usage.input_tokens), @@ -1207,4 +2638,13 @@ mod tests { "external_sandbox" ); } + + #[test] + fn guardian_review_result_maps_terminal_statuses() { + assert!(guardian_review_result(GuardianApprovalReviewStatus::InProgress).is_none()); + assert!(matches!( + guardian_review_result(GuardianApprovalReviewStatus::TimedOut), + Some((ReviewStatus::TimedOut, ReviewResolution::None)) + )); + } } diff --git a/codex-rs/ansi-escape/Cargo.toml b/codex-rs/ansi-escape/Cargo.toml index 3ebad2bdef..9e0f8a8123 100644 --- a/codex-rs/ansi-escape/Cargo.toml +++ b/codex-rs/ansi-escape/Cargo.toml @@ -7,6 +7,8 @@ license.workspace = true [lib] name = "codex_ansi_escape" path = "src/lib.rs" +test = false +doctest = false [lints] workspace = true diff --git a/codex-rs/app-server-client/Cargo.toml b/codex-rs/app-server-client/Cargo.toml index d9c1ade097..daa6ef661f 100644 --- a/codex-rs/app-server-client/Cargo.toml +++ b/codex-rs/app-server-client/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [lib] name = "codex_app_server_client" path = "src/lib.rs" +doctest = false [lints] workspace = true @@ -20,6 +21,8 @@ codex-core = { workspace = true } codex-exec-server = { workspace = true } codex-feedback = { workspace = true } codex-protocol = { workspace = true } +codex-uds = { workspace = true } +codex-utils-absolute-path = { workspace = true } codex-utils-rustls-provider = { workspace = true } futures = { workspace = true } serde = { workspace = true } @@ -33,4 +36,5 @@ url = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } serde_json = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index bbbb109eff..cd5dcf6649 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -25,10 +25,12 @@ use std::io::Result as IoResult; use std::sync::Arc; use std::time::Duration; +pub use codex_app_server::app_server_control_socket_path; pub use codex_app_server::in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; pub use codex_app_server::in_process::InProcessServerEvent; use codex_app_server::in_process::InProcessStartArgs; use codex_app_server::in_process::LogDbLayer; +pub use codex_app_server::in_process::StateDbHandle; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::ClientRequest; @@ -48,7 +50,6 @@ use codex_config::RemoteThreadConfigLoader; use codex_config::ThreadConfigLoader; use codex_core::config::Config; pub use codex_exec_server::EnvironmentManager; -pub use codex_exec_server::EnvironmentManagerArgs; pub use codex_exec_server::ExecServerRuntimePaths; use codex_feedback::CodexFeedback; use codex_protocol::protocol::SessionSource; @@ -61,6 +62,7 @@ use tracing::warn; pub use crate::remote::RemoteAppServerClient; pub use crate::remote::RemoteAppServerConnectArgs; +pub use crate::remote::RemoteAppServerEndpoint; /// Transitional access to core-only embedded app-server types. /// @@ -71,12 +73,9 @@ pub mod legacy_core { pub use codex_core::DEFAULT_AGENTS_MD_FILENAME; pub use codex_core::LOCAL_AGENTS_MD_FILENAME; pub use codex_core::McpManager; - pub use codex_core::append_message_history_entry; pub use codex_core::check_execpolicy_for_warnings; pub use codex_core::format_exec_policy_error_with_source; pub use codex_core::grant_read_root_non_elevated; - pub use codex_core::lookup_message_history_entry; - pub use codex_core::message_history_metadata; pub use codex_core::web_search_detail; pub mod config { @@ -343,6 +342,8 @@ pub struct InProcessClientStartArgs { pub feedback: CodexFeedback, /// SQLite tracing layer used to flush recently emitted logs before feedback upload. pub log_db: Option, + /// Process-wide SQLite state handle shared with the embedded app-server. + pub state_db: Option, /// Environment manager used by core execution and filesystem operations. pub environment_manager: Arc, /// Startup warnings emitted after initialize succeeds. @@ -375,6 +376,7 @@ impl InProcessClientStartArgs { pub fn initialize_params(&self) -> InitializeParams { let capabilities = InitializeCapabilities { experimental_api: self.experimental_api, + request_attestation: false, opt_out_notification_methods: if self.opt_out_notification_methods.is_empty() { None } else { @@ -404,6 +406,7 @@ impl InProcessClientStartArgs { thread_config_loader, feedback: self.feedback, log_db: self.log_db, + state_db: self.state_db, environment_manager: self.environment_manager, config_warnings: self.config_warnings, session_source: self.session_source, @@ -950,12 +953,19 @@ mod tests { use codex_app_server_protocol::ToolRequestUserInputParams; use codex_app_server_protocol::ToolRequestUserInputQuestion; use codex_core::config::ConfigBuilder; + use codex_core::init_state_db; + use codex_uds::UnixListener; + use codex_utils_absolute_path::AbsolutePathBuf; use futures::SinkExt; use futures::StreamExt; use pretty_assertions::assert_eq; + use std::ops::Deref; + use std::path::Path; + use tempfile::TempDir; use tokio::net::TcpListener; use tokio::time::Duration; use tokio::time::timeout; + use tokio_tungstenite::accept_async; use tokio_tungstenite::accept_hdr_async; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::handshake::server::Request as WebSocketRequest; @@ -971,18 +981,59 @@ mod tests { } } + async fn build_test_config_for_codex_home(codex_home: &Path) -> Config { + match ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .build() + .await + { + Ok(config) => config, + Err(_) => Config::load_default_with_cli_overrides_for_codex_home( + codex_home.to_path_buf(), + Vec::new(), + ) + .await + .expect("default config should load"), + } + } + + struct TestClient { + _codex_home: TempDir, + client: InProcessAppServerClient, + } + + impl Deref for TestClient { + type Target = InProcessAppServerClient; + + fn deref(&self) -> &Self::Target { + &self.client + } + } + + impl TestClient { + async fn shutdown(self) -> IoResult<()> { + self.client.shutdown().await + } + } + async fn start_test_client_with_capacity( session_source: SessionSource, channel_capacity: usize, - ) -> InProcessAppServerClient { - InProcessAppServerClient::start(InProcessClientStartArgs { + ) -> TestClient { + let codex_home = TempDir::new().expect("temp dir"); + let config = Arc::new(build_test_config_for_codex_home(codex_home.path()).await); + let state_db = init_state_db(config.as_ref()) + .await + .expect("state db should initialize for in-process test"); + let client = InProcessAppServerClient::start(InProcessClientStartArgs { arg0_paths: Arg0DispatchPaths::default(), - config: Arc::new(build_test_config().await), + config, cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), cloud_requirements: CloudRequirementsLoader::default(), feedback: CodexFeedback::new(), log_db: None, + state_db: Some(state_db), environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source, @@ -994,10 +1045,15 @@ mod tests { channel_capacity, }) .await - .expect("in-process app-server client should start") + .expect("in-process app-server client should start"); + + TestClient { + _codex_home: codex_home, + client, + } } - async fn start_test_client(session_source: SessionSource) -> InProcessAppServerClient { + async fn start_test_client(session_source: SessionSource) -> TestClient { start_test_client_with_capacity(session_source, DEFAULT_IN_PROCESS_CHANNEL_CAPACITY).await } @@ -1049,9 +1105,10 @@ mod tests { format!("ws://{addr}") } - async fn expect_remote_initialize( - websocket: &mut tokio_tungstenite::WebSocketStream, - ) { + async fn expect_remote_initialize(websocket: &mut tokio_tungstenite::WebSocketStream) + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { let JSONRPCMessage::Request(request) = read_websocket_message(websocket).await else { panic!("expected initialize request"); }; @@ -1072,9 +1129,12 @@ mod tests { assert_eq!(notification.method, "initialized"); } - async fn read_websocket_message( - websocket: &mut tokio_tungstenite::WebSocketStream, - ) -> JSONRPCMessage { + async fn read_websocket_message( + websocket: &mut tokio_tungstenite::WebSocketStream, + ) -> JSONRPCMessage + where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { loop { let frame = websocket .next() @@ -1094,10 +1154,12 @@ mod tests { } } - async fn write_websocket_message( - websocket: &mut tokio_tungstenite::WebSocketStream, + async fn write_websocket_message( + websocket: &mut tokio_tungstenite::WebSocketStream, message: JSONRPCMessage, - ) { + ) where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + { websocket .send(Message::Text( serde_json::to_string(&message) @@ -1134,6 +1196,7 @@ mod tests { ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification { thread_id: "thread".to_string(), turn_id: "turn".to_string(), + completed_at_ms: 0, item: codex_app_server_protocol::ThreadItem::AgentMessage { id: "item".to_string(), text: text.to_string(), @@ -1148,6 +1211,7 @@ mod tests { thread_id: "thread".to_string(), turn: codex_app_server_protocol::Turn { id: "turn".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, @@ -1160,8 +1224,10 @@ mod tests { fn test_remote_connect_args(websocket_url: String) -> RemoteAppServerConnectArgs { RemoteAppServerConnectArgs { - websocket_url, - auth_token: None, + endpoint: RemoteAppServerEndpoint::WebSocket { + websocket_url, + auth_token: None, + }, client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, @@ -1400,6 +1466,64 @@ mod tests { client.shutdown().await.expect("shutdown should complete"); } + #[tokio::test] + async fn remote_unix_socket_typed_request_roundtrip_works() { + let socket_dir = TempDir::new().expect("socket dir"); + let socket_path = AbsolutePathBuf::from_absolute_path(socket_dir.path().join("codex.sock")) + .expect("socket path should resolve"); + let mut listener = UnixListener::bind(socket_path.as_path()) + .await + .expect("listener should bind"); + tokio::spawn(async move { + let stream = listener.accept().await.expect("accept should succeed"); + let mut websocket = accept_async(stream) + .await + .expect("websocket upgrade should succeed"); + expect_remote_initialize(&mut websocket).await; + let JSONRPCMessage::Request(request) = read_websocket_message(&mut websocket).await + else { + panic!("expected account/read request"); + }; + assert_eq!(request.method, "account/read"); + write_websocket_message( + &mut websocket, + JSONRPCMessage::Response(JSONRPCResponse { + id: request.id, + result: serde_json::to_value(GetAccountResponse { + account: None, + requires_openai_auth: false, + }) + .expect("response should serialize"), + }), + ) + .await; + websocket.close(None).await.expect("close should succeed"); + }); + let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { + endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path }, + client_name: "codex-app-server-client-test".to_string(), + client_version: "0.0.0-test".to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity: 8, + }) + .await + .expect("remote client should connect"); + + let response: GetAccountResponse = client + .request_typed(ClientRequest::GetAccount { + request_id: RequestId::Integer(1), + params: codex_app_server_protocol::GetAccountParams { + refresh_token: false, + }, + }) + .await + .expect("typed request should succeed"); + assert_eq!(response.account, None); + + client.shutdown().await.expect("shutdown should complete"); + } + #[tokio::test] async fn remote_typed_request_accepts_large_single_frame_response() { let padding = "x".repeat((17 << 20) + 1024); @@ -1461,8 +1585,15 @@ mod tests { ) .await; let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { - auth_token: Some(auth_token), - ..test_remote_connect_args(websocket_url) + endpoint: RemoteAppServerEndpoint::WebSocket { + websocket_url, + auth_token: Some(auth_token), + }, + client_name: "codex-app-server-client-test".to_string(), + client_version: "0.0.0-test".to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity: 8, }) .await .expect("remote client should connect"); @@ -1473,9 +1604,15 @@ mod tests { #[tokio::test] async fn remote_connect_rejects_non_loopback_ws_when_auth_configured() { let result = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { - websocket_url: "ws://example.com:4500".to_string(), - auth_token: Some("remote-bearer-token".to_string()), - ..test_remote_connect_args("ws://127.0.0.1:1".to_string()) + endpoint: RemoteAppServerEndpoint::WebSocket { + websocket_url: "ws://example.com:4500".to_string(), + auth_token: Some("remote-bearer-token".to_string()), + }, + client_name: "codex-app-server-client-test".to_string(), + client_version: "0.0.0-test".to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity: 8, }) .await; let err = match result { @@ -1653,13 +1790,8 @@ mod tests { }) .await; let mut client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { - websocket_url, - auth_token: None, - client_name: "codex-app-server-client-test".to_string(), - client_version: "0.0.0-test".to_string(), - experimental_api: true, - opt_out_notification_methods: Vec::new(), channel_capacity: 1, + ..test_remote_connect_args(websocket_url) }) .await .expect("remote client should connect"); @@ -1978,6 +2110,7 @@ mod tests { thread_id: "thread".to_string(), turn: codex_app_server_protocol::Turn { id: "turn".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: codex_app_server_protocol::TurnStatus::Completed, error: None, @@ -2007,6 +2140,7 @@ mod tests { codex_app_server_protocol::ItemCompletedNotification { thread_id: "thread".to_string(), turn_id: "turn".to_string(), + completed_at_ms: 0, item: codex_app_server_protocol::ThreadItem::AgentMessage { id: "item".to_string(), text: "hello".to_string(), @@ -2057,6 +2191,7 @@ mod tests { cloud_requirements: CloudRequirementsLoader::default(), feedback: CodexFeedback::new(), log_db: None, + state_db: None, environment_manager: environment_manager.clone(), config_warnings: Vec::new(), session_source: SessionSource::Exec, @@ -2096,6 +2231,7 @@ mod tests { cloud_requirements: CloudRequirementsLoader::default(), feedback: CodexFeedback::new(), log_db: None, + state_db: None, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source: SessionSource::Exec, diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index d75534c160..dd9c083e49 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -1,11 +1,13 @@ /* -This module implements the websocket-backed app-server client transport. +This module implements the remote app-server client transport. It owns the remote connection lifecycle, including the initialize/initialized handshake, JSON-RPC request/response routing, server-request resolution, and -notification streaming. The rest of the crate uses the same `AppServerEvent` -surface for both in-process and remote transports, so callers such as the TUI -can switch between them without changing their higher-level session logic. +notification streaming. Remote connections always carry WebSocket frames, over +either TCP WebSocket URLs or local Unix sockets. The rest of the crate uses the +same `AppServerEvent` surface for both in-process and remote transports, so +callers such as the TUI can switch between them without changing their +higher-level session logic. */ use std::collections::HashMap; @@ -35,17 +37,23 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result as JsonRpcResult; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; +use codex_uds::UnixStream; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_rustls_provider::ensure_rustls_crypto_provider; use futures::SinkExt; use futures::StreamExt; use serde::de::DeserializeOwned; +use tokio::io::AsyncRead; +use tokio::io::AsyncWrite; use tokio::net::TcpStream; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::time::timeout; use tokio_tungstenite::MaybeTlsStream; use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::client_async_with_config; use tokio_tungstenite::connect_async_with_config; +use tokio_tungstenite::tungstenite::Error as TungsteniteError; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::http::HeaderValue; @@ -57,22 +65,35 @@ use url::Url; const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(10); const REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE: usize = 128 << 20; +// Tungstenite still needs an HTTP request URI for the WebSocket handshake; +// the bytes travel over the Unix socket, not TCP. +const UDS_WEBSOCKET_HANDSHAKE_URL: &str = "ws://localhost/rpc"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoteAppServerEndpoint { + WebSocket { + websocket_url: String, + auth_token: Option, + }, + UnixSocket { + socket_path: AbsolutePathBuf, + }, +} #[derive(Debug, Clone)] pub struct RemoteAppServerConnectArgs { - pub websocket_url: String, - pub auth_token: Option, + pub endpoint: RemoteAppServerEndpoint, pub client_name: String, pub client_version: String, pub experimental_api: bool, pub opt_out_notification_methods: Vec, pub channel_capacity: usize, } - impl RemoteAppServerConnectArgs { fn initialize_params(&self) -> InitializeParams { let capabilities = InitializeCapabilities { experimental_api: self.experimental_api, + request_attestation: false, opt_out_notification_methods: if self.opt_out_notification_methods.is_empty() { None } else { @@ -140,69 +161,39 @@ pub struct RemoteAppServerRequestHandle { impl RemoteAppServerClient { pub async fn connect(args: RemoteAppServerConnectArgs) -> IoResult { let channel_capacity = args.channel_capacity.max(1); - let websocket_url = args.websocket_url.clone(); - let url = Url::parse(&websocket_url).map_err(|err| { - IoError::new( - ErrorKind::InvalidInput, - format!("invalid websocket URL `{websocket_url}`: {err}"), - ) - })?; - if args.auth_token.is_some() && !websocket_url_supports_auth_token(&url) { - return Err(IoError::new( - ErrorKind::InvalidInput, - format!( - "remote auth tokens require `wss://` or loopback `ws://` URLs; got `{websocket_url}`" - ), - )); + let initialize_params = args.initialize_params(); + match args.endpoint { + RemoteAppServerEndpoint::WebSocket { + websocket_url, + auth_token, + } => { + let (endpoint, stream) = + connect_websocket_endpoint(websocket_url, auth_token).await?; + Self::connect_with_stream(channel_capacity, endpoint, stream, initialize_params) + .await + } + RemoteAppServerEndpoint::UnixSocket { socket_path } => { + let (endpoint, stream) = connect_unix_socket_endpoint(socket_path).await?; + Self::connect_with_stream(channel_capacity, endpoint, stream, initialize_params) + .await + } } - let mut request = url.as_str().into_client_request().map_err(|err| { - IoError::new( - ErrorKind::InvalidInput, - format!("invalid websocket URL `{websocket_url}`: {err}"), - ) - })?; - if let Some(auth_token) = args.auth_token.as_deref() { - let header_value = - HeaderValue::from_str(&format!("Bearer {auth_token}")).map_err(|err| { - IoError::new( - ErrorKind::InvalidInput, - format!("invalid remote authorization header value: {err}"), - ) - })?; - request.headers_mut().insert(AUTHORIZATION, header_value); - } - ensure_rustls_crypto_provider(); - // Remote resume responses can legitimately carry large thread histories. - // Keep a bounded cap, but raise it above tungstenite's 16 MiB frame default. - let websocket_config = WebSocketConfig::default() - .max_frame_size(Some(REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE)) - .max_message_size(Some(REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE)); - let stream = timeout( - CONNECT_TIMEOUT, - connect_async_with_config( - request, - Some(websocket_config), - /*disable_nagle*/ false, - ), - ) - .await - .map_err(|_| { - IoError::new( - ErrorKind::TimedOut, - format!("timed out connecting to remote app server at `{websocket_url}`"), - ) - })? - .map(|(stream, _response)| stream) - .map_err(|err| { - IoError::other(format!( - "failed to connect to remote app server at `{websocket_url}`: {err}" - )) - })?; + } + + async fn connect_with_stream( + channel_capacity: usize, + endpoint: String, + stream: WebSocketStream, + initialize_params: InitializeParams, + ) -> IoResult + where + S: AsyncRead + AsyncWrite + Unpin + Send + 'static, + { let mut stream = stream; let pending_events = initialize_remote_connection( &mut stream, - &websocket_url, - args.initialize_params(), + &endpoint, + initialize_params, INITIALIZE_TIMEOUT, ) .await?; @@ -234,13 +225,13 @@ impl RemoteAppServerClient { if let Err(err) = write_jsonrpc_message( &mut stream, JSONRPCMessage::Request(jsonrpc_request_from_client_request(*request)), - &websocket_url, + &endpoint, ) .await { let err_message = err.to_string(); let message = format!( - "remote app server at `{websocket_url}` write failed: {err_message}" + "remote app server at `{endpoint}` write failed: {err_message}" ); if let Some(response_tx) = pending_requests.remove(&request_id) { let _ = response_tx.send(Err(err)); @@ -261,7 +252,7 @@ impl RemoteAppServerClient { JSONRPCMessage::Notification( jsonrpc_notification_from_client_notification(notification), ), - &websocket_url, + &endpoint, ) .await; let _ = response_tx.send(result); @@ -277,7 +268,7 @@ impl RemoteAppServerClient { id: request_id, result, }), - &websocket_url, + &endpoint, ) .await; let _ = response_tx.send(result); @@ -293,16 +284,20 @@ impl RemoteAppServerClient { error, id: request_id, }), - &websocket_url, + &endpoint, ) .await; let _ = response_tx.send(result); } RemoteClientCommand::Shutdown { response_tx } => { - let close_result = stream.close(None).await.map_err(|err| { - IoError::other(format!( - "failed to close websocket app server `{websocket_url}`: {err}" - )) + let close_result = stream.close(None).await.or_else(|err| { + if websocket_close_error_is_already_closed(&err) { + Ok(()) + } else { + Err(IoError::other(format!( + "failed to close websocket app server `{endpoint}`: {err}" + ))) + } }); let _ = response_tx.send(close_result); break; @@ -363,13 +358,13 @@ impl RemoteAppServerClient { }, id: request_id, }), - &websocket_url, + &endpoint, ) .await { let err_message = reject_err.to_string(); let message = format!( - "remote app server at `{websocket_url}` write failed: {err_message}" + "remote app server at `{endpoint}` write failed: {err_message}" ); let _ = deliver_event( &event_tx, @@ -386,7 +381,7 @@ impl RemoteAppServerClient { } Err(err) => { let message = format!( - "remote app server at `{websocket_url}` sent invalid JSON-RPC: {err}" + "remote app server at `{endpoint}` sent invalid JSON-RPC: {err}" ); let _ = deliver_event( &event_tx, @@ -407,7 +402,7 @@ impl RemoteAppServerClient { .filter(|reason| !reason.is_empty()) .unwrap_or_else(|| "connection closed".to_string()); let message = format!( - "remote app server at `{websocket_url}` disconnected: {reason}" + "remote app server at `{endpoint}` disconnected: {reason}" ); let _ = deliver_event( &event_tx, @@ -427,7 +422,7 @@ impl RemoteAppServerClient { | Some(Ok(Message::Frame(_))) => {} Some(Err(err)) => { let message = format!( - "remote app server at `{websocket_url}` transport failed: {err}" + "remote app server at `{endpoint}` transport failed: {err}" ); let _ = deliver_event( &event_tx, @@ -440,7 +435,7 @@ impl RemoteAppServerClient { } None => { let message = format!( - "remote app server at `{websocket_url}` closed the connection" + "remote app server at `{endpoint}` closed the connection" ); let _ = deliver_event( &event_tx, @@ -677,12 +672,131 @@ impl RemoteAppServerRequestHandle { } } -async fn initialize_remote_connection( - stream: &mut WebSocketStream>, - websocket_url: &str, +async fn connect_websocket_endpoint( + websocket_url: String, + auth_token: Option, +) -> IoResult<(String, WebSocketStream>)> { + let url = Url::parse(&websocket_url).map_err(|err| { + IoError::new( + ErrorKind::InvalidInput, + format!("invalid websocket URL `{websocket_url}`: {err}"), + ) + })?; + if auth_token.is_some() && !websocket_url_supports_auth_token(&url) { + return Err(IoError::new( + ErrorKind::InvalidInput, + format!( + "remote auth tokens require `wss://` or loopback `ws://` URLs; got `{websocket_url}`" + ), + )); + } + + let mut request = url.as_str().into_client_request().map_err(|err| { + IoError::new( + ErrorKind::InvalidInput, + format!("invalid websocket URL `{websocket_url}`: {err}"), + ) + })?; + if let Some(auth_token) = auth_token.as_deref() { + let header_value = + HeaderValue::from_str(&format!("Bearer {auth_token}")).map_err(|err| { + IoError::new( + ErrorKind::InvalidInput, + format!("invalid remote authorization header value: {err}"), + ) + })?; + request.headers_mut().insert(AUTHORIZATION, header_value); + } + + ensure_rustls_crypto_provider(); + let websocket_config = remote_websocket_config(); + let stream = timeout( + CONNECT_TIMEOUT, + connect_async_with_config( + request, + Some(websocket_config), + /*disable_nagle*/ false, + ), + ) + .await + .map_err(|_| { + IoError::new( + ErrorKind::TimedOut, + format!("timed out connecting to remote app server at `{websocket_url}`"), + ) + })? + .map(|(stream, _response)| stream) + .map_err(|err| { + IoError::other(format!( + "failed to connect to remote app server at `{websocket_url}`: {err}" + )) + })?; + + Ok((websocket_url, stream)) +} + +async fn connect_unix_socket_endpoint( + socket_path: AbsolutePathBuf, +) -> IoResult<(String, WebSocketStream)> { + let endpoint = format!("unix://{}", socket_path.display()); + let request = UDS_WEBSOCKET_HANDSHAKE_URL + .into_client_request() + .map_err(|err| { + IoError::new( + ErrorKind::InvalidInput, + format!("invalid UDS websocket handshake URL: {err}"), + ) + })?; + let stream = timeout(CONNECT_TIMEOUT, UnixStream::connect(socket_path.as_path())) + .await + .map_err(|_| { + IoError::new( + ErrorKind::TimedOut, + format!("timed out connecting to remote app server at `{endpoint}`"), + ) + })? + .map_err(|err| { + IoError::other(format!( + "failed to connect to remote app server at `{endpoint}`: {err}" + )) + })?; + let websocket_config = remote_websocket_config(); + let stream = timeout( + CONNECT_TIMEOUT, + client_async_with_config(request, stream, Some(websocket_config)), + ) + .await + .map_err(|_| { + IoError::new( + ErrorKind::TimedOut, + format!("timed out upgrading remote app server at `{endpoint}`"), + ) + })? + .map(|(stream, _response)| stream) + .map_err(|err| { + IoError::other(format!( + "failed to upgrade remote app server at `{endpoint}`: {err}" + )) + })?; + + Ok((endpoint, stream)) +} + +fn remote_websocket_config() -> WebSocketConfig { + WebSocketConfig::default() + .max_frame_size(Some(REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE)) + .max_message_size(Some(REMOTE_APP_SERVER_MAX_WEBSOCKET_MESSAGE_SIZE)) +} + +async fn initialize_remote_connection( + stream: &mut WebSocketStream, + endpoint: &str, params: InitializeParams, initialize_timeout: Duration, -) -> IoResult> { +) -> IoResult> +where + S: AsyncRead + AsyncWrite + Unpin, +{ let initialize_request_id = RequestId::String("initialize".to_string()); let mut pending_events = Vec::new(); write_jsonrpc_message( @@ -693,7 +807,7 @@ async fn initialize_remote_connection( params, }, )), - websocket_url, + endpoint, ) .await?; @@ -703,7 +817,7 @@ async fn initialize_remote_connection( Some(Ok(Message::Text(text))) => { let message = serde_json::from_str::(&text).map_err(|err| { IoError::other(format!( - "remote app server at `{websocket_url}` sent invalid initialize response: {err}" + "remote app server at `{endpoint}` sent invalid initialize response: {err}" )) })?; match message { @@ -712,7 +826,7 @@ async fn initialize_remote_connection( } JSONRPCMessage::Error(error) if error.id == initialize_request_id => { break Err(IoError::other(format!( - "remote app server at `{websocket_url}` rejected initialize: {}", + "remote app server at `{endpoint}` rejected initialize: {}", error.error.message ))); } @@ -742,7 +856,7 @@ async fn initialize_remote_connection( }, id: request_id, }), - websocket_url, + endpoint, ) .await?; } @@ -764,19 +878,19 @@ async fn initialize_remote_connection( break Err(IoError::new( ErrorKind::ConnectionAborted, format!( - "remote app server at `{websocket_url}` closed during initialize: {reason}" + "remote app server at `{endpoint}` closed during initialize: {reason}" ), )); } Some(Err(err)) => { break Err(IoError::other(format!( - "remote app server at `{websocket_url}` transport failed during initialize: {err}" + "remote app server at `{endpoint}` transport failed during initialize: {err}" ))); } None => { break Err(IoError::new( ErrorKind::UnexpectedEof, - format!("remote app server at `{websocket_url}` closed during initialize"), + format!("remote app server at `{endpoint}` closed during initialize"), )); } } @@ -786,7 +900,7 @@ async fn initialize_remote_connection( .map_err(|_| { IoError::new( ErrorKind::TimedOut, - format!("timed out waiting for initialize response from `{websocket_url}`"), + format!("timed out waiting for initialize response from `{endpoint}`"), ) })??; @@ -795,7 +909,7 @@ async fn initialize_remote_connection( JSONRPCMessage::Notification(jsonrpc_notification_from_client_notification( ClientNotification::Initialized, )), - websocket_url, + endpoint, ) .await?; @@ -849,21 +963,35 @@ fn jsonrpc_notification_from_client_notification( } } -async fn write_jsonrpc_message( - stream: &mut WebSocketStream>, +async fn write_jsonrpc_message( + stream: &mut WebSocketStream, message: JSONRPCMessage, - websocket_url: &str, -) -> IoResult<()> { + endpoint: &str, +) -> IoResult<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ let payload = serde_json::to_string(&message).map_err(IoError::other)?; stream .send(Message::Text(payload.into())) .await .map_err(|err| { IoError::other(format!( - "failed to write websocket message to `{websocket_url}`: {err}" + "failed to write websocket message to `{endpoint}`: {err}" )) }) } + +fn websocket_close_error_is_already_closed(err: &TungsteniteError) -> bool { + match err { + TungsteniteError::ConnectionClosed | TungsteniteError::AlreadyClosed => true, + TungsteniteError::Io(err) => matches!( + err.kind(), + ErrorKind::BrokenPipe | ErrorKind::ConnectionReset | ErrorKind::NotConnected + ), + _ => false, + } +} #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/app-server-daemon/BUILD.bazel b/codex-rs/app-server-daemon/BUILD.bazel new file mode 100644 index 0000000000..1bca6d55db --- /dev/null +++ b/codex-rs/app-server-daemon/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "app-server-daemon", + crate_name = "codex_app_server_daemon", +) diff --git a/codex-rs/app-server-daemon/Cargo.toml b/codex-rs/app-server-daemon/Cargo.toml new file mode 100644 index 0000000000..24531b4c74 --- /dev/null +++ b/codex-rs/app-server-daemon/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "codex-app-server-daemon" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_app_server_daemon" +path = "src/lib.rs" +doctest = false + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +codex-app-server-protocol = { workspace = true } +codex-app-server-transport = { workspace = true } +codex-utils-home-dir = { workspace = true } +codex-uds = { workspace = true } +futures = { workspace = true } +libc = { workspace = true } +reqwest = { workspace = true, features = ["rustls-tls"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha2 = { workspace = true } +tokio = { workspace = true, features = [ + "fs", + "io-util", + "macros", + "process", + "rt-multi-thread", + "signal", + "time", +] } +tokio-tungstenite = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/app-server-daemon/README.md b/codex-rs/app-server-daemon/README.md new file mode 100644 index 0000000000..ac512a8037 --- /dev/null +++ b/codex-rs/app-server-daemon/README.md @@ -0,0 +1,113 @@ +# codex-app-server-daemon + +> `codex-app-server-daemon` is experimental and its lifecycle contract may +> change while the remote-management flow is still being developed. + +`codex-app-server-daemon` backs the machine-readable `codex app-server` +lifecycle commands used by remote clients such as the desktop and mobile apps. +It is intended for Codex instances launched over SSH, including fresh developer +machines that should expose app-server with `remote_control` enabled. + +## Platform support + +The current daemon implementation is Unix-only. It uses pidfile-backed +daemonization plus Unix process and file-locking primitives, and does not yet +support Windows lifecycle management. + +## Commands + +```sh +codex app-server daemon start +codex app-server daemon restart +codex app-server daemon enable-remote-control +codex app-server daemon disable-remote-control +codex app-server daemon stop +codex app-server daemon version +codex app-server daemon bootstrap --remote-control +``` + +On success, every command writes exactly one JSON object to stdout. Consumers +should parse that JSON rather than relying on human-readable text. Lifecycle +responses report the resolved backend, socket path, local CLI version, and +running app-server version when applicable. + +## Bootstrap flow + +For a new remote machine: + +```sh +curl -fsSL https://chatgpt.com/codex/install.sh | sh +$HOME/.codex/packages/standalone/current/codex app-server daemon bootstrap --remote-control +``` + +`bootstrap` requires the standalone managed install. It records the daemon +settings under `CODEX_HOME/app-server-daemon/`, starts app-server as a +pidfile-backed detached process, and launches a detached updater loop. + +## Installation and update cases + +The daemon assumes Codex is installed through `install.sh` and always launches +the standalone managed binary under `CODEX_HOME`. + +| Situation | What starts | Does this daemon fetch new binaries? | Does a running app-server eventually move to a newer binary on its own? | +| --- | --- | --- | --- | +| `install.sh` has run, but only `start` is used | `start` uses `CODEX_HOME/packages/standalone/current/codex` | No | No. The managed path is used when starting or restarting, but no updater is installed. | +| `install.sh` has run, then `bootstrap` is used | The pidfile backend uses `CODEX_HOME/packages/standalone/current/codex` | Yes. Bootstrap launches a detached updater loop that runs `install.sh` hourly. | Yes, while that updater process is alive and app-server is already running. After a successful fetch, the updater restarts app-server with the refreshed binary and only then replaces its own process image. | +| Some other tool updates the managed binary path | The next fresh start or restart uses the updated file at that path | Only if `bootstrap` is active, because the updater still runs `install.sh` on its normal cadence. | Without `bootstrap`, no. With `bootstrap`, the next successful updater pass compares the managed binary contents after `install.sh` runs; if app-server is running and they differ from the updater's current image, it refreshes app-server first and then itself. | + +### Standalone installs + +For installs created by `install.sh`: + +- lifecycle commands always use the standalone managed binary path +- `bootstrap` is supported +- `bootstrap` starts a detached pid-backed updater loop that fetches via + `install.sh` +- after a successful refresh, if app-server is running and the managed binary + contents changed, the updater restarts app-server with that binary first and + only then replaces its own process image +- the updater loop is not reboot-persistent; it must be started again by + rerunning `bootstrap` after a reboot + +### Out-of-band updates + +This daemon does not watch arbitrary executable files for replacement. If some +other tool updates the managed binary path: + +- without `bootstrap`, a currently running app-server remains on the old + executable image until an explicit `restart` +- with `bootstrap`, the detached updater loop notices the changed managed + binary on its next successful scheduled pass after running `install.sh`; if + app-server is running, it refreshes app-server first and then refreshes itself + once that replacement starts successfully + +## Lifecycle semantics + +`start` is idempotent and returns after app-server is ready to answer the normal +JSON-RPC initialize handshake on the Unix control socket. + +`restart` stops any managed daemon and starts it again. + +`enable-remote-control` and `disable-remote-control` persist the launch setting +for future starts. If a managed app-server is already running, they restart it +so the new setting takes effect immediately. + +Top-level `codex remote-control` bootstraps with `--remote-control` when the +updater loop is not running. Otherwise it enables remote control and starts the +daemon normally. + +`stop` sends a graceful termination request first, then sends a second +termination signal after the grace window if the process is still alive. + +All mutating lifecycle commands are serialized per `CODEX_HOME`, so a concurrent +`start`, `restart`, `enable-remote-control`, `disable-remote-control`, `stop`, +or `bootstrap` does not race another in-flight lifecycle operation. + +## State + +The daemon stores its local state under `CODEX_HOME/app-server-daemon/`: + +- `settings.json` for persisted launch settings +- `app-server.pid` for the app-server process record +- `app-server-updater.pid` for the pid-backed standalone updater loop +- `daemon.lock` for daemon-wide lifecycle serialization diff --git a/codex-rs/app-server-daemon/src/backend/mod.rs b/codex-rs/app-server-daemon/src/backend/mod.rs new file mode 100644 index 0000000000..5e92dd5261 --- /dev/null +++ b/codex-rs/app-server-daemon/src/backend/mod.rs @@ -0,0 +1,33 @@ +mod pid; + +use std::path::PathBuf; + +use serde::Serialize; + +pub(crate) use pid::PidBackend; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum BackendKind { + Pid, +} + +#[derive(Debug, Clone)] +pub(crate) struct BackendPaths { + pub(crate) codex_bin: PathBuf, + pub(crate) pid_file: PathBuf, + pub(crate) update_pid_file: PathBuf, + pub(crate) remote_control_enabled: bool, +} + +pub(crate) fn pid_backend(paths: BackendPaths) -> PidBackend { + PidBackend::new( + paths.codex_bin, + paths.pid_file, + paths.remote_control_enabled, + ) +} + +pub(crate) fn pid_update_loop_backend(paths: BackendPaths) -> PidBackend { + PidBackend::new_update_loop(paths.codex_bin, paths.update_pid_file) +} diff --git a/codex-rs/app-server-daemon/src/backend/pid.rs b/codex-rs/app-server-daemon/src/backend/pid.rs new file mode 100644 index 0000000000..7f8aa1660d --- /dev/null +++ b/codex-rs/app-server-daemon/src/backend/pid.rs @@ -0,0 +1,594 @@ +use std::path::Path; +use std::path::PathBuf; +#[cfg(unix)] +use std::process::Stdio; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use serde::Deserialize; +use serde::Serialize; +use tokio::fs; +#[cfg(unix)] +use tokio::process::Command; +use tokio::time::sleep; + +const STOP_POLL_INTERVAL: Duration = Duration::from_millis(50); +const STOP_GRACE_PERIOD: Duration = Duration::from_secs(60); +const STOP_TIMEOUT: Duration = Duration::from_secs(70); +const START_TIMEOUT: Duration = Duration::from_secs(10); + +#[derive(Debug)] +#[cfg_attr(not(unix), allow(dead_code))] +pub(crate) struct PidBackend { + codex_bin: PathBuf, + pid_file: PathBuf, + lock_file: PathBuf, + command_kind: PidCommandKind, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PidRecord { + pid: u32, + process_start_time: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PidFileState { + Missing, + Starting, + Running(PidRecord), +} + +#[derive(Debug, Clone, Copy)] +#[cfg_attr(not(unix), allow(dead_code))] +enum PidCommandKind { + AppServer { remote_control_enabled: bool }, + UpdateLoop, +} + +impl PidBackend { + pub(crate) fn new(codex_bin: PathBuf, pid_file: PathBuf, remote_control_enabled: bool) -> Self { + let lock_file = pid_file.with_extension("pid.lock"); + Self { + codex_bin, + pid_file, + lock_file, + command_kind: PidCommandKind::AppServer { + remote_control_enabled, + }, + } + } + + pub(crate) fn new_update_loop(codex_bin: PathBuf, pid_file: PathBuf) -> Self { + let lock_file = pid_file.with_extension("pid.lock"); + Self { + codex_bin, + pid_file, + lock_file, + command_kind: PidCommandKind::UpdateLoop, + } + } + + pub(crate) async fn is_starting_or_running(&self) -> Result { + loop { + match self.read_pid_file_state().await? { + PidFileState::Missing => return Ok(false), + PidFileState::Starting => return Ok(true), + PidFileState::Running(record) => { + if self.record_is_active(&record).await? { + return Ok(true); + } + match self.refresh_after_stale_record(&record).await? { + PidFileState::Missing => return Ok(false), + PidFileState::Starting | PidFileState::Running(_) => continue, + } + } + } + } + } + + #[cfg(unix)] + pub(crate) async fn start(&self) -> Result> { + if let Some(parent) = self.pid_file.parent() { + fs::create_dir_all(parent) + .await + .with_context(|| format!("failed to create pid directory {}", parent.display()))?; + } + let reservation_lock = self.acquire_reservation_lock().await?; + let _pid_file = loop { + match fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&self.pid_file) + .await + { + Ok(pid_file) => break pid_file, + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + match self.read_pid_file_state_with_lock_held().await? { + PidFileState::Missing => continue, + PidFileState::Running(record) => { + if self.record_is_active(&record).await? { + return Ok(None); + } + let _ = fs::remove_file(&self.pid_file).await; + continue; + } + PidFileState::Starting => { + unreachable!("lock holder cannot observe starting") + } + } + } + Err(err) => { + return Err(err).with_context(|| { + format!("failed to reserve pid file {}", self.pid_file.display()) + }); + } + } + }; + let mut command = Command::new(&self.codex_bin); + command + .args(self.command_args()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + #[cfg(unix)] + { + unsafe { + command.pre_exec(|| { + if libc::setsid() == -1 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) + }); + } + } + + let child = match command.spawn() { + Ok(child) => child, + Err(err) => { + let _ = fs::remove_file(&self.pid_file).await; + return Err(err).with_context(|| { + format!( + "failed to spawn detached app-server process using {}", + self.codex_bin.display() + ) + }); + } + }; + let pid = child + .id() + .context("spawned app-server process has no pid")?; + let record = match read_process_start_time(pid).await { + Ok(process_start_time) => PidRecord { + pid, + process_start_time, + }, + Err(err) => { + let _ = self.terminate_process(pid); + let _ = fs::remove_file(&self.pid_file).await; + return Err(err); + } + }; + let contents = serde_json::to_vec(&record).context("failed to serialize pid record")?; + let temp_pid_file = self.pid_file.with_extension("pid.tmp"); + if let Err(err) = fs::write(&temp_pid_file, &contents).await { + let _ = self.terminate_process(pid); + let _ = fs::remove_file(&self.pid_file).await; + return Err(err).with_context(|| { + format!("failed to write pid temp file {}", temp_pid_file.display()) + }); + } + if let Err(err) = fs::rename(&temp_pid_file, &self.pid_file).await { + let _ = self.terminate_process(pid); + let _ = fs::remove_file(&temp_pid_file).await; + let _ = fs::remove_file(&self.pid_file).await; + return Err(err).with_context(|| { + format!("failed to publish pid file {}", self.pid_file.display()) + }); + } + drop(reservation_lock); + Ok(Some(pid)) + } + + #[cfg(not(unix))] + pub(crate) async fn start(&self) -> Result> { + bail!("pid-managed app-server startup is unsupported on this platform") + } + + pub(crate) async fn stop(&self) -> Result<()> { + loop { + let Some(record) = self.wait_for_pid_start().await? else { + return Ok(()); + }; + if !self.record_is_active(&record).await? { + match self.refresh_after_stale_record(&record).await? { + PidFileState::Missing => return Ok(()), + PidFileState::Starting | PidFileState::Running(_) => continue, + } + } + + let pid = record.pid; + self.terminate_process(pid)?; + let started_at = tokio::time::Instant::now(); + let deadline = tokio::time::Instant::now() + STOP_TIMEOUT; + let mut forced = false; + while tokio::time::Instant::now() < deadline { + if !self.record_is_active(&record).await? { + match self.refresh_after_stale_record(&record).await? { + PidFileState::Missing => return Ok(()), + PidFileState::Starting | PidFileState::Running(_) => break, + } + } + if !forced && started_at.elapsed() >= STOP_GRACE_PERIOD { + self.force_terminate_process(pid)?; + forced = true; + } + sleep(STOP_POLL_INTERVAL).await; + } + + if self.record_is_active(&record).await? { + bail!("timed out waiting for pid-managed app server {pid} to stop"); + } + } + } + + async fn wait_for_pid_start(&self) -> Result> { + let deadline = tokio::time::Instant::now() + START_TIMEOUT; + loop { + match self.read_pid_file_state().await? { + PidFileState::Missing => return Ok(None), + PidFileState::Running(record) => return Ok(Some(record)), + PidFileState::Starting if tokio::time::Instant::now() < deadline => { + sleep(STOP_POLL_INTERVAL).await; + } + PidFileState::Starting => { + bail!( + "timed out waiting for pid reservation in {} to finish initializing", + self.pid_file.display() + ); + } + } + } + } + + async fn read_pid_file_state(&self) -> Result { + let contents = match fs::read_to_string(&self.pid_file).await { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return if reservation_lock_is_active(&self.lock_file).await? { + Ok(PidFileState::Starting) + } else { + Ok(PidFileState::Missing) + }; + } + Err(err) => { + return Err(err).with_context(|| { + format!("failed to read pid file {}", self.pid_file.display()) + }); + } + }; + if contents.trim().is_empty() { + match inspect_empty_pid_reservation(&self.pid_file, &self.lock_file).await? { + EmptyPidReservation::Active => { + return Ok(PidFileState::Starting); + } + EmptyPidReservation::Stale => { + return Ok(PidFileState::Missing); + } + EmptyPidReservation::Record(record) => return Ok(PidFileState::Running(record)), + } + } + let record = serde_json::from_str(&contents) + .with_context(|| format!("invalid pid file contents in {}", self.pid_file.display()))?; + Ok(PidFileState::Running(record)) + } + + async fn read_pid_file_state_with_lock_held(&self) -> Result { + let contents = match fs::read_to_string(&self.pid_file).await { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(PidFileState::Missing); + } + Err(err) => { + return Err(err).with_context(|| { + format!("failed to read pid file {}", self.pid_file.display()) + }); + } + }; + if contents.trim().is_empty() { + let _ = fs::remove_file(&self.pid_file).await; + return Ok(PidFileState::Missing); + } + let record = serde_json::from_str(&contents) + .with_context(|| format!("invalid pid file contents in {}", self.pid_file.display()))?; + Ok(PidFileState::Running(record)) + } + + async fn refresh_after_stale_record(&self, expected: &PidRecord) -> Result { + let reservation_lock = self.acquire_reservation_lock().await?; + let state = match self.read_pid_file_state_with_lock_held().await? { + PidFileState::Running(record) if record == *expected => { + let _ = fs::remove_file(&self.pid_file).await; + PidFileState::Missing + } + state => state, + }; + drop(reservation_lock); + Ok(state) + } + + async fn acquire_reservation_lock(&self) -> Result { + let reservation_lock = fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&self.lock_file) + .await + .with_context(|| { + format!("failed to open pid lock file {}", self.lock_file.display()) + })?; + let lock_deadline = tokio::time::Instant::now() + START_TIMEOUT; + while !try_lock_file(&reservation_lock)? { + if tokio::time::Instant::now() >= lock_deadline { + bail!( + "timed out waiting for pid lock {}", + self.lock_file.display() + ); + } + sleep(STOP_POLL_INTERVAL).await; + } + Ok(reservation_lock) + } + + #[cfg(unix)] + fn command_args(&self) -> Vec<&'static str> { + match self.command_kind { + PidCommandKind::AppServer { + remote_control_enabled: true, + } => vec!["app-server", "--remote-control", "--listen", "unix://"], + PidCommandKind::AppServer { + remote_control_enabled: false, + } => vec!["app-server", "--listen", "unix://"], + PidCommandKind::UpdateLoop => vec!["app-server", "daemon", "pid-update-loop"], + } + } + + fn terminate_process(&self, pid: u32) -> Result<()> { + match self.command_kind { + PidCommandKind::AppServer { .. } => terminate_process(pid), + PidCommandKind::UpdateLoop => terminate_process(pid), + } + } + + fn force_terminate_process(&self, pid: u32) -> Result<()> { + match self.command_kind { + PidCommandKind::AppServer { .. } => force_terminate_process(pid), + PidCommandKind::UpdateLoop => force_terminate_process_group(pid), + } + } + + async fn record_is_active(&self, record: &PidRecord) -> Result { + process_matches_record(record).await + } +} + +#[cfg(unix)] +fn process_exists(pid: u32) -> bool { + let Ok(pid) = libc::pid_t::try_from(pid) else { + return false; + }; + let result = unsafe { libc::kill(pid, 0) }; + result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) +} + +#[cfg(unix)] +fn terminate_process(pid: u32) -> Result<()> { + let raw_pid = libc::pid_t::try_from(pid) + .with_context(|| format!("pid-managed app server pid {pid} is out of range"))?; + let result = unsafe { libc::kill(raw_pid, libc::SIGTERM) }; + if result == 0 { + return Ok(()); + } + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::ESRCH) { + return Ok(()); + } + Err(err).with_context(|| format!("failed to terminate pid-managed app server {pid}")) +} + +#[cfg(unix)] +fn force_terminate_process(pid: u32) -> Result<()> { + let raw_pid = libc::pid_t::try_from(pid) + .with_context(|| format!("pid-managed app server pid {pid} is out of range"))?; + let result = unsafe { libc::kill(raw_pid, libc::SIGKILL) }; + if result == 0 { + return Ok(()); + } + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::ESRCH) { + return Ok(()); + } + Err(err).with_context(|| format!("failed to force terminate pid-managed app server {pid}")) +} + +#[cfg(unix)] +fn force_terminate_process_group(pid: u32) -> Result<()> { + let raw_pid = libc::pid_t::try_from(pid) + .with_context(|| format!("pid-managed updater pid {pid} is out of range"))?; + let result = unsafe { libc::kill(-raw_pid, libc::SIGKILL) }; + if result == 0 { + return Ok(()); + } + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::ESRCH) { + return Ok(()); + } + Err(err).with_context(|| format!("failed to force terminate pid-managed updater group {pid}")) +} + +#[cfg(not(unix))] +fn terminate_process(_pid: u32) -> Result<()> { + bail!("pid-managed app-server shutdown is unsupported on this platform") +} + +#[cfg(not(unix))] +fn force_terminate_process(_pid: u32) -> Result<()> { + bail!("pid-managed app-server shutdown is unsupported on this platform") +} + +#[cfg(not(unix))] +fn force_terminate_process_group(_pid: u32) -> Result<()> { + bail!("pid-managed updater shutdown is unsupported on this platform") +} + +#[cfg(unix)] +async fn process_matches_record(record: &PidRecord) -> Result { + if !process_exists(record.pid) { + return Ok(false); + } + + match read_process_start_time(record.pid).await { + Ok(start_time) => Ok(start_time == record.process_start_time), + Err(_err) if !process_exists(record.pid) => Ok(false), + Err(err) => Err(err), + } +} + +#[cfg(not(unix))] +async fn process_matches_record(_record: &PidRecord) -> Result { + Ok(false) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(not(unix), allow(dead_code))] +enum EmptyPidReservation { + Active, + Stale, + Record(PidRecord), +} + +#[cfg(unix)] +fn try_lock_file(file: &fs::File) -> Result { + use std::os::fd::AsRawFd; + + let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if result == 0 { + return Ok(true); + } + + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EWOULDBLOCK) { + return Ok(false); + } + Err(err).context("failed to lock pid reservation") +} + +#[cfg(not(unix))] +fn try_lock_file(_file: &fs::File) -> Result { + bail!("pid-managed app-server startup is unsupported on this platform") +} + +#[cfg(unix)] +async fn reservation_lock_is_active(path: &Path) -> Result { + let file = match fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .open(path) + .await + { + Ok(file) => file, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(false); + } + Err(err) => { + return Err(err) + .with_context(|| format!("failed to inspect pid lock file {}", path.display())); + } + }; + Ok(!try_lock_file(&file)?) +} + +#[cfg(not(unix))] +async fn reservation_lock_is_active(_path: &Path) -> Result { + Ok(false) +} + +#[cfg(unix)] +async fn inspect_empty_pid_reservation( + pid_path: &Path, + lock_path: &Path, +) -> Result { + let file = match fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(false) + .open(lock_path) + .await + { + Ok(file) => file, + Err(err) => { + return Err(err).with_context(|| { + format!("failed to inspect pid lock file {}", lock_path.display()) + }); + } + }; + if !try_lock_file(&file)? { + return Ok(EmptyPidReservation::Active); + } + + let contents = match fs::read_to_string(pid_path).await { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return Ok(EmptyPidReservation::Stale); + } + Err(err) => { + return Err(err) + .with_context(|| format!("failed to reread pid file {}", pid_path.display())); + } + }; + if contents.trim().is_empty() { + let _ = fs::remove_file(pid_path).await; + return Ok(EmptyPidReservation::Stale); + } + + let record = serde_json::from_str(&contents) + .with_context(|| format!("invalid pid file contents in {}", pid_path.display()))?; + Ok(EmptyPidReservation::Record(record)) +} + +#[cfg(not(unix))] +async fn inspect_empty_pid_reservation( + _pid_path: &Path, + _lock_path: &Path, +) -> Result { + Ok(EmptyPidReservation::Stale) +} + +#[cfg(unix)] +async fn read_process_start_time(pid: u32) -> Result { + let output = Command::new("ps") + .args(["-p", &pid.to_string(), "-o", "lstart="]) + .output() + .await + .context("failed to invoke ps for pid-managed app server")?; + if !output.status.success() { + bail!("failed to read start time for pid-managed app server {pid}"); + } + + let start_time = String::from_utf8(output.stdout) + .context("pid-managed app server start time was not utf-8")?; + let start_time = start_time.trim(); + if start_time.is_empty() { + bail!("pid-managed app server {pid} has no recorded start time"); + } + Ok(start_time.to_string()) +} + +#[cfg(all(test, unix))] +#[path = "pid_tests.rs"] +mod tests; diff --git a/codex-rs/app-server-daemon/src/backend/pid_tests.rs b/codex-rs/app-server-daemon/src/backend/pid_tests.rs new file mode 100644 index 0000000000..dc279794c2 --- /dev/null +++ b/codex-rs/app-server-daemon/src/backend/pid_tests.rs @@ -0,0 +1,158 @@ +use std::time::Duration; + +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +use super::PidBackend; +use super::PidCommandKind; +use super::PidFileState; +use super::PidRecord; +use super::try_lock_file; + +#[tokio::test] +async fn locked_empty_pid_file_is_treated_as_active_reservation() { + let temp_dir = TempDir::new().expect("temp dir"); + let pid_file = temp_dir.path().join("app-server.pid"); + tokio::fs::write(&pid_file, "") + .await + .expect("write pid file"); + let backend = PidBackend::new( + temp_dir.path().join("codex"), + pid_file.clone(), + /*remote_control_enabled*/ false, + ); + let reservation = tokio::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&backend.lock_file) + .await + .expect("open pid lock file"); + assert!(try_lock_file(&reservation).expect("lock reservation")); + + assert_eq!( + backend.read_pid_file_state().await.expect("read pid"), + PidFileState::Starting + ); + assert!(pid_file.exists()); +} + +#[tokio::test] +async fn unlocked_empty_pid_file_is_treated_as_stale_reservation() { + let temp_dir = TempDir::new().expect("temp dir"); + let pid_file = temp_dir.path().join("app-server.pid"); + tokio::fs::write(&pid_file, "") + .await + .expect("write pid file"); + let backend = PidBackend::new( + temp_dir.path().join("codex"), + pid_file.clone(), + /*remote_control_enabled*/ false, + ); + + assert_eq!( + backend.read_pid_file_state().await.expect("read pid"), + PidFileState::Missing + ); + assert!(!pid_file.exists()); +} + +#[tokio::test] +async fn stop_waits_for_live_reservation_to_resolve() { + let temp_dir = TempDir::new().expect("temp dir"); + let pid_file = temp_dir.path().join("app-server.pid"); + tokio::fs::write(&pid_file, "") + .await + .expect("write pid file"); + let backend = PidBackend::new( + temp_dir.path().join("codex"), + pid_file.clone(), + /*remote_control_enabled*/ false, + ); + let reservation = tokio::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&backend.lock_file) + .await + .expect("open pid lock file"); + assert!(try_lock_file(&reservation).expect("lock reservation")); + let cleanup = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(50)).await; + drop(reservation); + tokio::fs::remove_file(pid_file) + .await + .expect("remove pid file"); + }); + + backend.stop().await.expect("stop"); + cleanup.await.expect("cleanup task"); +} + +#[tokio::test] +async fn start_retries_stale_empty_pid_file_under_its_own_lock() { + let temp_dir = TempDir::new().expect("temp dir"); + let pid_file = temp_dir.path().join("app-server.pid"); + tokio::fs::write(&pid_file, "") + .await + .expect("write pid file"); + let backend = PidBackend::new( + temp_dir.path().join("missing-codex"), + pid_file, + /*remote_control_enabled*/ false, + ); + + let err = backend.start().await.expect_err("start"); + assert!( + err.to_string() + .starts_with("failed to spawn detached app-server process using ") + ); +} + +#[tokio::test] +async fn stale_record_cleanup_preserves_replacement_record() { + let temp_dir = TempDir::new().expect("temp dir"); + let pid_file = temp_dir.path().join("app-server.pid"); + let backend = PidBackend::new( + temp_dir.path().join("codex"), + pid_file.clone(), + /*remote_control_enabled*/ false, + ); + let stale = PidRecord { + pid: 1, + process_start_time: "old".to_string(), + }; + let replacement = PidRecord { + pid: 2, + process_start_time: "new".to_string(), + }; + tokio::fs::write( + &pid_file, + serde_json::to_vec(&replacement).expect("serialize replacement"), + ) + .await + .expect("write replacement pid file"); + + assert_eq!( + backend + .refresh_after_stale_record(&stale) + .await + .expect("cleanup"), + PidFileState::Running(replacement) + ); +} + +#[test] +fn update_loop_uses_hidden_app_server_subcommand() { + let backend = PidBackend { + codex_bin: "codex".into(), + pid_file: "updater.pid".into(), + lock_file: "updater.pid.lock".into(), + command_kind: PidCommandKind::UpdateLoop, + }; + + assert_eq!( + backend.command_args(), + vec!["app-server", "daemon", "pid-update-loop"] + ); +} diff --git a/codex-rs/app-server-daemon/src/client.rs b/codex-rs/app-server-daemon/src/client.rs new file mode 100644 index 0000000000..44fccda394 --- /dev/null +++ b/codex-rs/app-server-daemon/src/client.rs @@ -0,0 +1,131 @@ +use std::path::Path; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::InitializeResponse; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCRequest; +use codex_app_server_protocol::RequestId; +use codex_uds::UnixStream; +use futures::SinkExt; +use futures::StreamExt; +use tokio::time::timeout; +use tokio_tungstenite::client_async; +use tokio_tungstenite::tungstenite::Message; + +const PROBE_TIMEOUT: Duration = Duration::from_secs(2); +const CLIENT_NAME: &str = "codex_app_server_daemon"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ProbeInfo { + pub(crate) app_server_version: String, +} + +pub(crate) async fn probe(socket_path: &Path) -> Result { + timeout(PROBE_TIMEOUT, probe_inner(socket_path)) + .await + .with_context(|| { + format!( + "timed out probing app-server control socket {}", + socket_path.display() + ) + })? +} + +async fn probe_inner(socket_path: &Path) -> Result { + let stream = UnixStream::connect(socket_path) + .await + .with_context(|| format!("failed to connect to {}", socket_path.display()))?; + let (mut websocket, _response) = client_async("ws://localhost/", stream) + .await + .with_context(|| format!("failed to upgrade {}", socket_path.display()))?; + + let initialize = JSONRPCMessage::Request(JSONRPCRequest { + id: RequestId::Integer(1), + method: "initialize".to_string(), + params: Some(serde_json::to_value(InitializeParams { + client_info: ClientInfo { + name: CLIENT_NAME.to_string(), + title: Some("Codex App Server Daemon".to_string()), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + capabilities: None, + })?), + trace: None, + }); + websocket + .send(Message::Text(serde_json::to_string(&initialize)?.into())) + .await + .context("failed to send initialize request")?; + + let response = loop { + let frame = websocket + .next() + .await + .ok_or_else(|| anyhow!("app-server closed before initialize response"))??; + let Message::Text(payload) = frame else { + continue; + }; + let message = serde_json::from_str::(&payload)?; + if let JSONRPCMessage::Response(response) = message + && response.id == RequestId::Integer(1) + { + break response; + } + }; + let initialize_response = serde_json::from_value::(response.result)?; + + let initialized = JSONRPCMessage::Notification(JSONRPCNotification { + method: "initialized".to_string(), + params: None, + }); + websocket + .send(Message::Text(serde_json::to_string(&initialized)?.into())) + .await + .context("failed to send initialized notification")?; + websocket.close(None).await.ok(); + + Ok(ProbeInfo { + app_server_version: parse_version_from_user_agent(&initialize_response.user_agent)?, + }) +} + +fn parse_version_from_user_agent(user_agent: &str) -> Result { + let (_originator, rest) = user_agent + .split_once('/') + .ok_or_else(|| anyhow!("app-server user-agent omitted version separator"))?; + let version = rest + .split_whitespace() + .next() + .filter(|version| !version.is_empty()) + .ok_or_else(|| anyhow!("app-server user-agent omitted version"))?; + Ok(version.to_string()) +} + +#[cfg(all(test, unix))] +mod tests { + use pretty_assertions::assert_eq; + + use super::parse_version_from_user_agent; + + #[test] + fn parses_version_from_codex_user_agent() { + assert_eq!( + parse_version_from_user_agent( + "codex_app_server_daemon/1.2.3 (Linux 6.8.0; x86_64) codex_cli_rs/1.2.3", + ) + .expect("version"), + "1.2.3" + ); + } + + #[test] + fn rejects_user_agent_without_version() { + assert!(parse_version_from_user_agent("codex_app_server_daemon").is_err()); + } +} diff --git a/codex-rs/app-server-daemon/src/lib.rs b/codex-rs/app-server-daemon/src/lib.rs new file mode 100644 index 0000000000..826acbae68 --- /dev/null +++ b/codex-rs/app-server-daemon/src/lib.rs @@ -0,0 +1,874 @@ +mod backend; +mod client; +mod managed_install; +mod settings; +mod update_loop; + +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +pub use backend::BackendKind; +use backend::BackendPaths; +use codex_app_server_transport::app_server_control_socket_path; +use codex_utils_home_dir::find_codex_home; +use managed_install::managed_codex_bin; +#[cfg(unix)] +use managed_install::managed_codex_version; +use serde::Serialize; +use settings::DaemonSettings; +use tokio::time::sleep; + +const START_POLL_INTERVAL: Duration = Duration::from_millis(50); +const START_TIMEOUT: Duration = Duration::from_secs(10); +const OPERATION_LOCK_TIMEOUT: Duration = Duration::from_secs(75); +const PID_FILE_NAME: &str = "app-server.pid"; +const UPDATE_PID_FILE_NAME: &str = "app-server-updater.pid"; +const OPERATION_LOCK_FILE_NAME: &str = "daemon.lock"; +const SETTINGS_FILE_NAME: &str = "settings.json"; +const STATE_DIR_NAME: &str = "app-server-daemon"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LifecycleCommand { + Start, + Restart, + Stop, + Version, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum LifecycleStatus { + AlreadyRunning, + Started, + Restarted, + Stopped, + NotRunning, + Running, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LifecycleOutput { + pub status: LifecycleStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub backend: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pid: Option, + pub socket_path: PathBuf, + #[serde(skip_serializing_if = "Option::is_none")] + pub cli_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub app_server_version: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BootstrapOptions { + pub remote_control_enabled: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum BootstrapStatus { + Bootstrapped, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BootstrapOutput { + pub status: BootstrapStatus, + pub backend: BackendKind, + pub auto_update_enabled: bool, + pub remote_control_enabled: bool, + pub managed_codex_path: PathBuf, + pub socket_path: PathBuf, + pub cli_version: String, + pub app_server_version: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(untagged)] +pub enum RemoteControlStartOutput { + Bootstrap(BootstrapOutput), + Start(LifecycleOutput), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoteControlMode { + Enabled, + Disabled, +} + +impl RemoteControlMode { + fn is_enabled(self) -> bool { + matches!(self, Self::Enabled) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum RemoteControlStatus { + Enabled, + Disabled, + AlreadyEnabled, + AlreadyDisabled, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteControlOutput { + pub status: RemoteControlStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub backend: Option, + pub remote_control_enabled: bool, + pub socket_path: PathBuf, + pub cli_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub app_server_version: Option, +} + +#[cfg(unix)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RestartIfRunningOutcome { + Busy, + NotRunning, + NotReady, + AlreadyCurrent, + Restarted, +} + +#[cfg(unix)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RestartMode { + IfVersionChanged, + Always, +} + +#[cfg(unix)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum UpdaterRefreshMode { + None, + ReexecIfManagedBinaryChanged, +} + +#[cfg(unix)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RestartDecision { + NotReady, + AlreadyCurrent, + Restart, +} + +pub async fn run(command: LifecycleCommand) -> Result { + ensure_supported_platform()?; + Daemon::from_environment()?.run(command).await +} + +pub async fn bootstrap(options: BootstrapOptions) -> Result { + ensure_supported_platform()?; + Daemon::from_environment()?.bootstrap(options).await +} + +pub async fn ensure_remote_control_started() -> Result { + ensure_supported_platform()?; + Daemon::from_environment()? + .ensure_remote_control_started() + .await +} + +pub async fn set_remote_control(mode: RemoteControlMode) -> Result { + ensure_supported_platform()?; + Daemon::from_environment()?.set_remote_control(mode).await +} + +pub async fn run_pid_update_loop() -> Result<()> { + ensure_supported_platform()?; + update_loop::run().await +} + +#[cfg(unix)] +fn ensure_supported_platform() -> Result<()> { + Ok(()) +} + +#[cfg(not(unix))] +fn ensure_supported_platform() -> Result<()> { + Err(anyhow!( + "codex app-server daemon lifecycle is only supported on Unix platforms" + )) +} + +struct Daemon { + socket_path: PathBuf, + pid_file: PathBuf, + update_pid_file: PathBuf, + operation_lock_file: PathBuf, + settings_file: PathBuf, + managed_codex_bin: PathBuf, +} + +impl Daemon { + fn from_environment() -> Result { + let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?; + let socket_path = app_server_control_socket_path(codex_home.as_path())? + .as_path() + .to_path_buf(); + let state_dir = codex_home.as_path().join(STATE_DIR_NAME); + Ok(Self { + socket_path, + pid_file: state_dir.join(PID_FILE_NAME), + update_pid_file: state_dir.join(UPDATE_PID_FILE_NAME), + operation_lock_file: state_dir.join(OPERATION_LOCK_FILE_NAME), + settings_file: state_dir.join(SETTINGS_FILE_NAME), + managed_codex_bin: managed_codex_bin(codex_home.as_path()), + }) + } + + async fn run(&self, command: LifecycleCommand) -> Result { + match command { + LifecycleCommand::Start => { + let _operation_lock = self.acquire_operation_lock().await?; + self.start().await + } + LifecycleCommand::Restart => { + let _operation_lock = self.acquire_operation_lock().await?; + self.restart().await + } + LifecycleCommand::Stop => { + let _operation_lock = self.acquire_operation_lock().await?; + self.stop().await + } + LifecycleCommand::Version => self.version().await, + } + } + + async fn start(&self) -> Result { + let settings = self.load_settings().await?; + if let Ok(info) = client::probe(&self.socket_path).await { + return Ok(self.output( + LifecycleStatus::AlreadyRunning, + self.running_backend(&settings).await?, + /*pid*/ None, + Some(info.app_server_version), + )); + } + + if self.running_backend_instance(&settings).await?.is_some() { + let info = self.wait_until_ready().await?; + return Ok(self.output( + LifecycleStatus::AlreadyRunning, + Some(BackendKind::Pid), + /*pid*/ None, + Some(info.app_server_version), + )); + } + + self.ensure_managed_codex_bin()?; + let pid = self.start_managed_backend(&settings).await?; + let info = self.wait_until_ready().await?; + Ok(self.output( + LifecycleStatus::Started, + Some(BackendKind::Pid), + pid, + Some(info.app_server_version), + )) + } + + async fn restart(&self) -> Result { + let settings = self.load_settings().await?; + if client::probe(&self.socket_path).await.is_ok() + && self.running_backend(&settings).await?.is_none() + { + return Err(anyhow!( + "app server is running but is not managed by codex app-server daemon" + )); + } + + self.ensure_managed_codex_bin()?; + if let Some(backend) = self.running_backend_instance(&settings).await? { + backend.stop().await?; + } + + let pid = self.start_managed_backend(&settings).await?; + let info = self.wait_until_ready().await?; + Ok(self.output( + LifecycleStatus::Restarted, + Some(BackendKind::Pid), + pid, + Some(info.app_server_version), + )) + } + + #[cfg(unix)] + pub(crate) async fn try_restart_if_running( + &self, + mode: RestartMode, + updater_refresh_mode: UpdaterRefreshMode, + managed_codex_bin: &Path, + ) -> Result { + let operation_lock = self.open_operation_lock_file().await?; + if !try_lock_file(&operation_lock)? { + return Ok(RestartIfRunningOutcome::Busy); + } + let settings = self.load_settings().await?; + let outcome = if let Some(backend) = self.running_backend_instance(&settings).await? { + let info = client::probe(&self.socket_path).await.ok(); + let managed_version = if info.is_some() { + Some(managed_codex_version(managed_codex_bin).await?) + } else { + None + }; + match restart_decision(mode, info.as_ref(), managed_version.as_deref()) { + RestartDecision::NotReady => return Ok(RestartIfRunningOutcome::NotReady), + RestartDecision::AlreadyCurrent => RestartIfRunningOutcome::AlreadyCurrent, + RestartDecision::Restart => { + backend.stop().await?; + let _ = self + .start_managed_backend_with_bin(&settings, managed_codex_bin) + .await?; + self.wait_until_ready().await?; + RestartIfRunningOutcome::Restarted + } + } + } else if client::probe(&self.socket_path).await.is_ok() { + return Err(anyhow!( + "app server is running but is not managed by codex app-server daemon" + )); + } else { + RestartIfRunningOutcome::NotRunning + }; + + if should_reexec_updater(updater_refresh_mode, outcome) { + crate::update_loop::reexec_managed_updater(managed_codex_bin)?; + } + + Ok(outcome) + } + + async fn stop(&self) -> Result { + let settings = self.load_settings().await?; + if let Some(backend) = self.running_backend_instance(&settings).await? { + backend.stop().await?; + return Ok(self.output( + LifecycleStatus::Stopped, + Some(BackendKind::Pid), + /*pid*/ None, + /*app_server_version*/ None, + )); + } + + if client::probe(&self.socket_path).await.is_ok() { + return Err(anyhow!( + "app server is running but is not managed by codex app-server daemon" + )); + } + + Ok(self.output( + LifecycleStatus::NotRunning, + /*backend*/ None, + /*pid*/ None, + /*app_server_version*/ None, + )) + } + + async fn version(&self) -> Result { + let settings = self.load_settings().await?; + let info = client::probe(&self.socket_path).await?; + Ok(self.output( + LifecycleStatus::Running, + self.running_backend(&settings).await?, + /*pid*/ None, + Some(info.app_server_version), + )) + } + + async fn wait_until_ready(&self) -> Result { + let deadline = tokio::time::Instant::now() + START_TIMEOUT; + loop { + match client::probe(&self.socket_path).await { + Ok(info) => return Ok(info), + Err(err) if tokio::time::Instant::now() < deadline => { + let _ = err; + sleep(START_POLL_INTERVAL).await; + } + Err(err) => { + return Err(err).with_context(|| { + format!( + "app server did not become ready on {}", + self.socket_path.display() + ) + }); + } + } + } + } + + async fn bootstrap(&self, options: BootstrapOptions) -> Result { + let _operation_lock = self.acquire_operation_lock().await?; + self.bootstrap_locked(options).await + } + + async fn ensure_remote_control_started(&self) -> Result { + let _operation_lock = self.acquire_operation_lock().await?; + let settings = self.load_settings().await?; + if self.is_bootstrapped(&settings).await? { + let _ = self + .set_remote_control_locked(RemoteControlMode::Enabled) + .await?; + let output = self.start().await?; + return Ok(RemoteControlStartOutput::Start(output)); + } + + let output = self + .bootstrap_locked(BootstrapOptions { + remote_control_enabled: true, + }) + .await?; + Ok(RemoteControlStartOutput::Bootstrap(output)) + } + + async fn set_remote_control(&self, mode: RemoteControlMode) -> Result { + let _operation_lock = self.acquire_operation_lock().await?; + self.set_remote_control_locked(mode).await + } + + async fn set_remote_control_locked( + &self, + mode: RemoteControlMode, + ) -> Result { + let previous_settings = self.load_settings().await?; + let mut settings = previous_settings.clone(); + let remote_control_enabled = mode.is_enabled(); + let backend = self.running_backend_instance(&previous_settings).await?; + + if backend.is_none() && client::probe(&self.socket_path).await.is_ok() { + return Err(anyhow!( + "app server is running but is not managed by codex app-server daemon" + )); + } + + if settings.remote_control_enabled == remote_control_enabled { + let info = if backend.is_some() { + Some(self.wait_until_ready().await?) + } else { + None + }; + return Ok(self.remote_control_output( + already_remote_control_status(mode), + backend.map(|_| BackendKind::Pid), + remote_control_enabled, + info.map(|info| info.app_server_version), + )); + } + + settings.remote_control_enabled = remote_control_enabled; + settings.save(&self.settings_file).await?; + + let app_server_version = if let Some(backend) = backend { + self.ensure_managed_codex_bin()?; + backend.stop().await?; + let _ = self.start_managed_backend(&settings).await?; + Some(self.wait_until_ready().await?.app_server_version) + } else { + None + }; + + Ok(self.remote_control_output( + remote_control_status(mode), + app_server_version.as_ref().map(|_| BackendKind::Pid), + remote_control_enabled, + app_server_version, + )) + } + + async fn bootstrap_locked(&self, options: BootstrapOptions) -> Result { + self.ensure_managed_codex_bin()?; + + let settings = DaemonSettings { + remote_control_enabled: options.remote_control_enabled, + }; + if client::probe(&self.socket_path).await.is_ok() + && self.running_backend(&settings).await?.is_none() + { + return Err(anyhow!( + "app server is running but is not managed by codex app-server daemon" + )); + } + settings.save(&self.settings_file).await?; + + if let Some(backend) = self.running_backend_instance(&settings).await? { + backend.stop().await?; + } + + let backend = backend::pid_backend(self.backend_paths(&settings)); + backend.start().await?; + let updater = backend::pid_update_loop_backend(self.backend_paths(&settings)); + if updater.is_starting_or_running().await? { + updater.stop().await?; + } + updater.start().await?; + + let info = self.wait_until_ready().await?; + Ok(BootstrapOutput { + status: BootstrapStatus::Bootstrapped, + backend: BackendKind::Pid, + auto_update_enabled: true, + remote_control_enabled: settings.remote_control_enabled, + managed_codex_path: self.managed_codex_bin.clone(), + socket_path: self.socket_path.clone(), + cli_version: env!("CARGO_PKG_VERSION").to_string(), + app_server_version: info.app_server_version, + }) + } + + async fn running_backend(&self, settings: &DaemonSettings) -> Result> { + Ok(self + .running_backend_instance(settings) + .await? + .map(|_| BackendKind::Pid)) + } + + async fn running_backend_instance( + &self, + settings: &DaemonSettings, + ) -> Result> { + let backend = backend::pid_backend(self.backend_paths(settings)); + if backend.is_starting_or_running().await? { + return Ok(Some(backend)); + } + Ok(None) + } + + async fn start_managed_backend(&self, settings: &DaemonSettings) -> Result> { + self.start_managed_backend_with_bin(settings, &self.managed_codex_bin) + .await + } + + async fn start_managed_backend_with_bin( + &self, + settings: &DaemonSettings, + managed_codex_bin: &Path, + ) -> Result> { + let backend = + backend::pid_backend(self.backend_paths_with_bin(settings, managed_codex_bin)); + backend.start().await + } + + async fn is_bootstrapped(&self, settings: &DaemonSettings) -> Result { + let updater = backend::pid_update_loop_backend(self.backend_paths(settings)); + updater.is_starting_or_running().await + } + + fn ensure_managed_codex_bin(&self) -> Result<()> { + if self.managed_codex_bin.is_file() { + return Ok(()); + } + + Err(anyhow!( + "managed standalone Codex install not found at {}; install Codex first", + self.managed_codex_bin.display() + )) + } + + fn backend_paths(&self, settings: &DaemonSettings) -> BackendPaths { + self.backend_paths_with_bin(settings, &self.managed_codex_bin) + } + + fn backend_paths_with_bin( + &self, + settings: &DaemonSettings, + managed_codex_bin: &Path, + ) -> BackendPaths { + BackendPaths { + codex_bin: managed_codex_bin.to_path_buf(), + pid_file: self.pid_file.clone(), + update_pid_file: self.update_pid_file.clone(), + remote_control_enabled: settings.remote_control_enabled, + } + } + + async fn load_settings(&self) -> Result { + DaemonSettings::load(&self.settings_file).await + } + + async fn acquire_operation_lock(&self) -> Result { + let operation_lock = self.open_operation_lock_file().await?; + let deadline = tokio::time::Instant::now() + OPERATION_LOCK_TIMEOUT; + while !try_lock_file(&operation_lock)? { + if tokio::time::Instant::now() >= deadline { + return Err(anyhow!( + "timed out waiting for daemon operation lock {}", + self.operation_lock_file.display() + )); + } + sleep(START_POLL_INTERVAL).await; + } + Ok(operation_lock) + } + + async fn open_operation_lock_file(&self) -> Result { + if let Some(parent) = self.operation_lock_file.parent() { + tokio::fs::create_dir_all(parent).await.with_context(|| { + format!( + "failed to create daemon state directory {}", + parent.display() + ) + })?; + } + tokio::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&self.operation_lock_file) + .await + .with_context(|| { + format!( + "failed to open daemon operation lock {}", + self.operation_lock_file.display() + ) + }) + } + + fn output( + &self, + status: LifecycleStatus, + backend: Option, + pid: Option, + app_server_version: Option, + ) -> LifecycleOutput { + LifecycleOutput { + status, + backend, + pid, + socket_path: self.socket_path.clone(), + cli_version: Some(env!("CARGO_PKG_VERSION").to_string()), + app_server_version, + } + } + + fn remote_control_output( + &self, + status: RemoteControlStatus, + backend: Option, + remote_control_enabled: bool, + app_server_version: Option, + ) -> RemoteControlOutput { + RemoteControlOutput { + status, + backend, + remote_control_enabled, + socket_path: self.socket_path.clone(), + cli_version: env!("CARGO_PKG_VERSION").to_string(), + app_server_version, + } + } +} + +fn remote_control_status(mode: RemoteControlMode) -> RemoteControlStatus { + match mode { + RemoteControlMode::Enabled => RemoteControlStatus::Enabled, + RemoteControlMode::Disabled => RemoteControlStatus::Disabled, + } +} + +fn already_remote_control_status(mode: RemoteControlMode) -> RemoteControlStatus { + match mode { + RemoteControlMode::Enabled => RemoteControlStatus::AlreadyEnabled, + RemoteControlMode::Disabled => RemoteControlStatus::AlreadyDisabled, + } +} + +#[cfg(unix)] +fn restart_decision( + mode: RestartMode, + info: Option<&client::ProbeInfo>, + managed_version: Option<&str>, +) -> RestartDecision { + match (mode, info, managed_version) { + (RestartMode::IfVersionChanged, None, _) => RestartDecision::NotReady, + (RestartMode::IfVersionChanged, Some(info), Some(managed_version)) + if info.app_server_version == managed_version => + { + RestartDecision::AlreadyCurrent + } + _ => RestartDecision::Restart, + } +} + +#[cfg(unix)] +fn should_reexec_updater( + updater_refresh_mode: UpdaterRefreshMode, + outcome: RestartIfRunningOutcome, +) -> bool { + updater_refresh_mode == UpdaterRefreshMode::ReexecIfManagedBinaryChanged + && outcome == RestartIfRunningOutcome::Restarted +} + +#[cfg(unix)] +fn try_lock_file(file: &tokio::fs::File) -> Result { + use std::os::fd::AsRawFd; + + let result = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; + if result == 0 { + return Ok(true); + } + + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::EWOULDBLOCK) { + return Ok(false); + } + Err(err).context("failed to lock daemon operation") +} + +#[cfg(not(unix))] +fn try_lock_file(_file: &tokio::fs::File) -> Result { + Ok(true) +} + +#[cfg(all(test, unix))] +mod tests { + use pretty_assertions::assert_eq; + + use super::BackendKind; + use super::BootstrapOutput; + use super::BootstrapStatus; + use super::LifecycleOutput; + use super::LifecycleStatus; + use super::RemoteControlStartOutput; + use super::RemoteControlStatus; + use super::RestartDecision; + use super::RestartIfRunningOutcome; + use super::RestartMode; + use super::UpdaterRefreshMode; + use super::restart_decision; + use super::should_reexec_updater; + use crate::client::ProbeInfo; + + #[test] + fn lifecycle_status_uses_camel_case_json() { + assert_eq!( + serde_json::to_string(&LifecycleStatus::AlreadyRunning).expect("serialize"), + "\"alreadyRunning\"" + ); + } + + #[test] + fn bootstrap_status_uses_camel_case_json() { + assert_eq!( + serde_json::to_string(&BootstrapStatus::Bootstrapped).expect("serialize"), + "\"bootstrapped\"" + ); + } + + #[test] + fn remote_control_status_uses_camel_case_json() { + assert_eq!( + serde_json::to_string(&RemoteControlStatus::AlreadyEnabled).expect("serialize"), + "\"alreadyEnabled\"" + ); + } + + #[test] + fn updater_reexec_waits_for_validated_restart() { + assert_eq!( + [ + RestartIfRunningOutcome::Busy, + RestartIfRunningOutcome::NotReady, + RestartIfRunningOutcome::AlreadyCurrent, + RestartIfRunningOutcome::NotRunning, + RestartIfRunningOutcome::Restarted, + ] + .map(|outcome| { + should_reexec_updater(UpdaterRefreshMode::ReexecIfManagedBinaryChanged, outcome) + }), + [false, false, false, false, true] + ); + } + + #[test] + fn unchanged_updater_never_reexecs() { + assert_eq!( + [ + RestartIfRunningOutcome::Busy, + RestartIfRunningOutcome::NotReady, + RestartIfRunningOutcome::AlreadyCurrent, + RestartIfRunningOutcome::NotRunning, + RestartIfRunningOutcome::Restarted, + ] + .map(|outcome| should_reexec_updater(UpdaterRefreshMode::None, outcome)), + [false, false, false, false, false] + ); + } + + #[test] + fn restart_decision_preserves_forced_refreshes() { + let current_info = ProbeInfo { + app_server_version: "0.1.0".to_string(), + }; + + assert_eq!( + [ + restart_decision( + RestartMode::IfVersionChanged, + Some(¤t_info), + Some("0.1.0"), + ), + restart_decision( + RestartMode::IfVersionChanged, + /*info*/ None, + /*managed_version*/ None, + ), + restart_decision(RestartMode::Always, Some(¤t_info), Some("0.1.0")), + restart_decision( + RestartMode::Always, + /*info*/ None, + /*managed_version*/ None, + ), + ], + [ + RestartDecision::AlreadyCurrent, + RestartDecision::NotReady, + RestartDecision::Restart, + RestartDecision::Restart, + ] + ); + } + + #[test] + fn remote_control_start_output_serializes_inner_output_without_tag() { + let lifecycle_output = LifecycleOutput { + status: LifecycleStatus::AlreadyRunning, + backend: Some(BackendKind::Pid), + pid: None, + socket_path: "codex.sock".into(), + cli_version: Some("1.2.3".to_string()), + app_server_version: Some("1.2.4".to_string()), + }; + let output = RemoteControlStartOutput::Start(lifecycle_output.clone()); + + assert_eq!( + serde_json::to_value(output).expect("serialize"), + serde_json::to_value(lifecycle_output).expect("serialize") + ); + + let bootstrap_output = BootstrapOutput { + status: BootstrapStatus::Bootstrapped, + backend: BackendKind::Pid, + auto_update_enabled: true, + remote_control_enabled: true, + managed_codex_path: "codex".into(), + socket_path: "codex.sock".into(), + cli_version: "1.2.3".to_string(), + app_server_version: "1.2.4".to_string(), + }; + let output = RemoteControlStartOutput::Bootstrap(bootstrap_output.clone()); + + assert_eq!( + serde_json::to_value(output).expect("serialize"), + serde_json::to_value(bootstrap_output).expect("serialize") + ); + } +} diff --git a/codex-rs/app-server-daemon/src/managed_install.rs b/codex-rs/app-server-daemon/src/managed_install.rs new file mode 100644 index 0000000000..d5ad85b986 --- /dev/null +++ b/codex-rs/app-server-daemon/src/managed_install.rs @@ -0,0 +1,103 @@ +use std::path::Path; +use std::path::PathBuf; + +#[cfg(unix)] +use anyhow::Context; +#[cfg(unix)] +use anyhow::Result; +#[cfg(unix)] +use anyhow::anyhow; +#[cfg(unix)] +use sha2::Digest; +#[cfg(unix)] +use sha2::Sha256; +#[cfg(unix)] +use tokio::fs; +#[cfg(unix)] +use tokio::process::Command; + +pub(crate) fn managed_codex_bin(codex_home: &Path) -> PathBuf { + codex_home + .join("packages") + .join("standalone") + .join("current") + .join(managed_codex_file_name()) +} + +#[cfg(unix)] +pub(crate) async fn resolved_managed_codex_bin(codex_bin: &Path) -> Result { + fs::canonicalize(codex_bin).await.with_context(|| { + format!( + "failed to resolve managed Codex binary {}", + codex_bin.display() + ) + }) +} + +#[cfg(unix)] +pub(crate) async fn managed_codex_version(codex_bin: &Path) -> Result { + let output = Command::new(codex_bin) + .arg("--version") + .output() + .await + .with_context(|| { + format!( + "failed to invoke managed Codex binary {}", + codex_bin.display() + ) + })?; + if !output.status.success() { + return Err(anyhow!( + "managed Codex binary {} exited with status {}", + codex_bin.display(), + output.status + )); + } + + let stdout = String::from_utf8(output.stdout).with_context(|| { + format!( + "managed Codex version was not utf-8: {}", + codex_bin.display() + ) + })?; + parse_codex_version(&stdout) +} + +#[cfg(unix)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExecutableIdentity { + digest: [u8; 32], +} + +#[cfg(unix)] +pub(crate) async fn executable_identity(executable: &Path) -> Result { + let bytes = fs::read(executable) + .await + .with_context(|| format!("failed to read executable {}", executable.display()))?; + Ok(executable_identity_from_bytes(&bytes)) +} + +#[cfg(unix)] +pub(crate) fn executable_identity_from_bytes(bytes: &[u8]) -> ExecutableIdentity { + ExecutableIdentity { + digest: Sha256::digest(bytes).into(), + } +} + +fn managed_codex_file_name() -> &'static str { + if cfg!(windows) { "codex.exe" } else { "codex" } +} + +#[cfg(unix)] +fn parse_codex_version(output: &str) -> Result { + let version = output + .split_whitespace() + .nth(1) + .filter(|version| !version.is_empty()) + .ok_or_else(|| anyhow!("managed Codex version output was malformed"))?; + Ok(version.to_string()) +} + +#[cfg(all(test, unix))] +#[path = "managed_install_tests.rs"] +mod tests; diff --git a/codex-rs/app-server-daemon/src/managed_install_tests.rs b/codex-rs/app-server-daemon/src/managed_install_tests.rs new file mode 100644 index 0000000000..93caa468e4 --- /dev/null +++ b/codex-rs/app-server-daemon/src/managed_install_tests.rs @@ -0,0 +1,27 @@ +use pretty_assertions::assert_eq; + +use super::executable_identity_from_bytes; +use super::parse_codex_version; + +#[test] +fn parses_codex_cli_version_output() { + assert_eq!( + parse_codex_version("codex 1.2.3\n").expect("version"), + "1.2.3" + ); +} + +#[test] +fn rejects_malformed_codex_cli_version_output() { + assert!(parse_codex_version("codex\n").is_err()); +} + +#[test] +fn executable_identity_uses_binary_contents() { + let old = executable_identity_from_bytes(b"old"); + let same = executable_identity_from_bytes(b"old"); + let new = executable_identity_from_bytes(b"new"); + + assert_eq!(old, same); + assert_ne!(old, new); +} diff --git a/codex-rs/app-server-daemon/src/settings.rs b/codex-rs/app-server-daemon/src/settings.rs new file mode 100644 index 0000000000..18b9c9faf7 --- /dev/null +++ b/codex-rs/app-server-daemon/src/settings.rs @@ -0,0 +1,63 @@ +use std::path::Path; + +use anyhow::Context; +use anyhow::Result; +use serde::Deserialize; +use serde::Serialize; +use tokio::fs; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DaemonSettings { + pub(crate) remote_control_enabled: bool, +} + +impl DaemonSettings { + pub(crate) async fn load(path: &Path) -> Result { + let contents = match fs::read_to_string(path).await { + Ok(contents) => contents, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()), + Err(err) => { + return Err(err) + .with_context(|| format!("failed to read daemon settings {}", path.display())); + } + }; + + serde_json::from_str(&contents) + .with_context(|| format!("failed to parse daemon settings {}", path.display())) + } + + pub(crate) async fn save(&self, path: &Path) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).await.with_context(|| { + format!( + "failed to create daemon settings directory {}", + parent.display() + ) + })?; + } + + let contents = serde_json::to_vec_pretty(self).context("failed to serialize settings")?; + fs::write(path, contents) + .await + .with_context(|| format!("failed to write daemon settings {}", path.display())) + } +} + +#[cfg(all(test, unix))] +mod tests { + use pretty_assertions::assert_eq; + + use super::DaemonSettings; + + #[test] + fn daemon_settings_use_camel_case_json() { + assert_eq!( + serde_json::to_string(&DaemonSettings { + remote_control_enabled: true, + }) + .expect("serialize"), + r#"{"remoteControlEnabled":true}"# + ); + } +} diff --git a/codex-rs/app-server-daemon/src/update_loop.rs b/codex-rs/app-server-daemon/src/update_loop.rs new file mode 100644 index 0000000000..0fe21e0a6d --- /dev/null +++ b/codex-rs/app-server-daemon/src/update_loop.rs @@ -0,0 +1,197 @@ +#[cfg(unix)] +use std::process::Command as StdCommand; +#[cfg(unix)] +use std::process::Stdio; +#[cfg(unix)] +use std::time::Duration; + +#[cfg(unix)] +use anyhow::Context; +use anyhow::Result; +#[cfg(not(unix))] +use anyhow::bail; +#[cfg(unix)] +use futures::FutureExt; +#[cfg(unix)] +use std::os::unix::process::CommandExt; +#[cfg(unix)] +use tokio::io::AsyncWriteExt; +#[cfg(unix)] +use tokio::process::Command; +#[cfg(unix)] +use tokio::signal::unix::Signal; +#[cfg(unix)] +use tokio::signal::unix::SignalKind; +#[cfg(unix)] +use tokio::signal::unix::signal; +#[cfg(unix)] +use tokio::time::sleep; + +#[cfg(unix)] +use crate::Daemon; +#[cfg(unix)] +use crate::RestartIfRunningOutcome; +#[cfg(unix)] +use crate::RestartMode; +#[cfg(unix)] +use crate::UpdaterRefreshMode; +#[cfg(unix)] +use crate::managed_install::ExecutableIdentity; +#[cfg(unix)] +use crate::managed_install::executable_identity; +#[cfg(unix)] +use crate::managed_install::resolved_managed_codex_bin; + +#[cfg(unix)] +const INITIAL_UPDATE_DELAY: Duration = Duration::from_secs(5 * 60); +#[cfg(unix)] +const RESTART_RETRY_INTERVAL: Duration = Duration::from_millis(50); +#[cfg(unix)] +const UPDATE_INTERVAL: Duration = Duration::from_secs(60 * 60); + +#[cfg(unix)] +pub(crate) async fn run() -> Result<()> { + let mut terminate = + signal(SignalKind::terminate()).context("failed to install updater shutdown handler")?; + let running_updater_identity = current_updater_identity().await?; + if sleep_or_terminate(INITIAL_UPDATE_DELAY, &mut terminate).await { + return Ok(()); + } + loop { + match update_once(&running_updater_identity, &mut terminate).await { + Ok(UpdateLoopControl::Continue) | Err(_) => {} + Ok(UpdateLoopControl::Stop) => return Ok(()), + } + if sleep_or_terminate(UPDATE_INTERVAL, &mut terminate).await { + return Ok(()); + } + } +} + +#[cfg(not(unix))] +pub(crate) async fn run() -> Result<()> { + bail!("pid-managed updater loop is unsupported on this platform") +} + +#[cfg(unix)] +async fn sleep_or_terminate(duration: Duration, terminate: &mut Signal) -> bool { + tokio::select! { + _ = sleep(duration) => false, + _ = terminate.recv() => true, + } +} + +#[cfg(unix)] +enum UpdateLoopControl { + Continue, + Stop, +} + +#[cfg(unix)] +async fn update_once( + running_updater_identity: &ExecutableIdentity, + terminate: &mut Signal, +) -> Result { + install_latest_standalone().await?; + + let daemon = Daemon::from_environment()?; + let managed_codex_bin = resolved_managed_codex_bin(&daemon.managed_codex_bin).await?; + let managed_identity = executable_identity(&managed_codex_bin).await?; + let (restart_mode, updater_refresh_mode) = + update_modes_for_identities(running_updater_identity, &managed_identity); + + loop { + if terminate.recv().now_or_never().flatten().is_some() { + return Ok(UpdateLoopControl::Stop); + } + match daemon + .try_restart_if_running(restart_mode, updater_refresh_mode, &managed_codex_bin) + .await? + { + RestartIfRunningOutcome::Busy => { + if sleep_or_terminate(RESTART_RETRY_INTERVAL, terminate).await { + return Ok(UpdateLoopControl::Stop); + } + } + _ => return Ok(UpdateLoopControl::Continue), + } + } +} + +#[cfg(unix)] +async fn current_updater_identity() -> Result { + let current_exe = + std::env::current_exe().context("failed to resolve current updater executable")?; + executable_identity(¤t_exe).await +} + +#[cfg(unix)] +fn update_modes_for_identities( + running_updater_identity: &ExecutableIdentity, + managed_identity: &ExecutableIdentity, +) -> (RestartMode, UpdaterRefreshMode) { + if running_updater_identity == managed_identity { + (RestartMode::IfVersionChanged, UpdaterRefreshMode::None) + } else { + ( + RestartMode::Always, + UpdaterRefreshMode::ReexecIfManagedBinaryChanged, + ) + } +} + +#[cfg(unix)] +pub(crate) fn reexec_managed_updater(managed_codex_bin: &std::path::Path) -> Result<()> { + let err = StdCommand::new(managed_codex_bin) + .args(["app-server", "daemon", "pid-update-loop"]) + .exec(); + Err(err).with_context(|| { + format!( + "failed to replace updater with managed Codex binary {}", + managed_codex_bin.display() + ) + }) +} + +#[cfg(unix)] +async fn install_latest_standalone() -> Result<()> { + let script = reqwest::get("https://chatgpt.com/codex/install.sh") + .await + .context("failed to fetch standalone Codex updater")? + .error_for_status() + .context("standalone Codex updater request failed")? + .bytes() + .await + .context("failed to read standalone Codex updater")?; + + let mut child = Command::new("/bin/sh") + .arg("-s") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("failed to invoke standalone Codex updater")?; + let mut stdin = child + .stdin + .take() + .context("standalone Codex updater stdin was unavailable")?; + stdin + .write_all(&script) + .await + .context("failed to pass standalone Codex updater to shell")?; + drop(stdin); + let status = child + .wait() + .await + .context("failed to wait for standalone Codex updater")?; + + if status.success() { + Ok(()) + } else { + anyhow::bail!("standalone Codex updater exited with status {status}") + } +} + +#[cfg(all(test, unix))] +#[path = "update_loop_tests.rs"] +mod tests; diff --git a/codex-rs/app-server-daemon/src/update_loop_tests.rs b/codex-rs/app-server-daemon/src/update_loop_tests.rs new file mode 100644 index 0000000000..cf270693aa --- /dev/null +++ b/codex-rs/app-server-daemon/src/update_loop_tests.rs @@ -0,0 +1,31 @@ +use pretty_assertions::assert_eq; + +use super::update_modes_for_identities; +use crate::RestartMode; +use crate::UpdaterRefreshMode; +use crate::managed_install::executable_identity_from_bytes; + +#[test] +fn unchanged_updater_uses_version_based_restart() { + assert_eq!( + update_modes_for_identities( + &executable_identity_from_bytes(b"same"), + &executable_identity_from_bytes(b"same"), + ), + (RestartMode::IfVersionChanged, UpdaterRefreshMode::None) + ); +} + +#[test] +fn changed_updater_forces_refresh_even_when_version_may_match() { + assert_eq!( + update_modes_for_identities( + &executable_identity_from_bytes(b"old"), + &executable_identity_from_bytes(b"new"), + ), + ( + RestartMode::Always, + UpdaterRefreshMode::ReexecIfManagedBinaryChanged, + ) + ); +} diff --git a/codex-rs/app-server-protocol/Cargo.toml b/codex-rs/app-server-protocol/Cargo.toml index 0cb50d8549..0749b07e08 100644 --- a/codex-rs/app-server-protocol/Cargo.toml +++ b/codex-rs/app-server-protocol/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [lib] name = "codex_app_server_protocol" path = "src/lib.rs" +doctest = false [lints] workspace = true diff --git a/codex-rs/app-server-protocol/schema/json/AttestationGenerateParams.json b/codex-rs/app-server-protocol/schema/json/AttestationGenerateParams.json new file mode 100644 index 0000000000..310552bb7d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/AttestationGenerateParams.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AttestationGenerateParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/DeviceKeyPublicParams.json b/codex-rs/app-server-protocol/schema/json/AttestationGenerateResponse.json similarity index 52% rename from codex-rs/app-server-protocol/schema/json/v2/DeviceKeyPublicParams.json rename to codex-rs/app-server-protocol/schema/json/AttestationGenerateResponse.json index 37cc5fbe2c..e6bd59ec25 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/DeviceKeyPublicParams.json +++ b/codex-rs/app-server-protocol/schema/json/AttestationGenerateResponse.json @@ -1,14 +1,14 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Fetch a controller-local device key public key by id.", "properties": { - "keyId": { + "token": { + "description": "Opaque client attestation token.", "type": "string" } }, "required": [ - "keyId" + "token" ], - "title": "DeviceKeyPublicParams", + "title": "AttestationGenerateResponse", "type": "object" } \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 37a64fbe33..6351993046 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -533,200 +533,6 @@ } ] }, - "DeviceKeyCreateParams": { - "description": "Create a controller-local device key with a random key id.", - "properties": { - "accountUserId": { - "type": "string" - }, - "clientId": { - "type": "string" - }, - "protectionPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/DeviceKeyProtectionPolicy" - }, - { - "type": "null" - } - ], - "description": "Defaults to `hardware_only` when omitted." - } - }, - "required": [ - "accountUserId", - "clientId" - ], - "type": "object" - }, - "DeviceKeyProtectionPolicy": { - "description": "Protection policy for creating or loading a controller-local device key.", - "enum": [ - "hardware_only", - "allow_os_protected_nonextractable" - ], - "type": "string" - }, - "DeviceKeyPublicParams": { - "description": "Fetch a controller-local device key public key by id.", - "properties": { - "keyId": { - "type": "string" - } - }, - "required": [ - "keyId" - ], - "type": "object" - }, - "DeviceKeySignParams": { - "description": "Sign an accepted structured payload with a controller-local device key.", - "properties": { - "keyId": { - "type": "string" - }, - "payload": { - "$ref": "#/definitions/DeviceKeySignPayload" - } - }, - "required": [ - "keyId", - "payload" - ], - "type": "object" - }, - "DeviceKeySignPayload": { - "description": "Structured payloads accepted by `device/key/sign`.", - "oneOf": [ - { - "description": "Payload bound to one remote-control controller websocket `/client` connection challenge.", - "properties": { - "accountUserId": { - "type": "string" - }, - "audience": { - "$ref": "#/definitions/RemoteControlClientConnectionAudience" - }, - "clientId": { - "type": "string" - }, - "nonce": { - "type": "string" - }, - "scopes": { - "description": "Must contain exactly `remote_control_controller_websocket`.", - "items": { - "type": "string" - }, - "type": "array" - }, - "sessionId": { - "description": "Backend-issued websocket session id that this proof authorizes.", - "type": "string" - }, - "targetOrigin": { - "description": "Origin of the backend endpoint that issued the challenge and will verify this proof.", - "type": "string" - }, - "targetPath": { - "description": "Websocket route path that this proof authorizes.", - "type": "string" - }, - "tokenExpiresAt": { - "description": "Remote-control token expiration as Unix seconds.", - "format": "int64", - "type": "integer" - }, - "tokenSha256Base64url": { - "description": "SHA-256 of the controller-scoped remote-control token, encoded as unpadded base64url.", - "type": "string" - }, - "type": { - "enum": [ - "remoteControlClientConnection" - ], - "title": "RemoteControlClientConnectionDeviceKeySignPayloadType", - "type": "string" - } - }, - "required": [ - "accountUserId", - "audience", - "clientId", - "nonce", - "scopes", - "sessionId", - "targetOrigin", - "targetPath", - "tokenExpiresAt", - "tokenSha256Base64url", - "type" - ], - "title": "RemoteControlClientConnectionDeviceKeySignPayload", - "type": "object" - }, - { - "description": "Payload bound to a remote-control client `/client/enroll` ownership challenge.", - "properties": { - "accountUserId": { - "type": "string" - }, - "audience": { - "$ref": "#/definitions/RemoteControlClientEnrollmentAudience" - }, - "challengeExpiresAt": { - "description": "Enrollment challenge expiration as Unix seconds.", - "format": "int64", - "type": "integer" - }, - "challengeId": { - "description": "Backend-issued enrollment challenge id that this proof authorizes.", - "type": "string" - }, - "clientId": { - "type": "string" - }, - "deviceIdentitySha256Base64url": { - "description": "SHA-256 of the requested device identity operation, encoded as unpadded base64url.", - "type": "string" - }, - "nonce": { - "type": "string" - }, - "targetOrigin": { - "description": "Origin of the backend endpoint that issued the challenge and will verify this proof.", - "type": "string" - }, - "targetPath": { - "description": "HTTP route path that this proof authorizes.", - "type": "string" - }, - "type": { - "enum": [ - "remoteControlClientEnrollment" - ], - "title": "RemoteControlClientEnrollmentDeviceKeySignPayloadType", - "type": "string" - } - }, - "required": [ - "accountUserId", - "audience", - "challengeExpiresAt", - "challengeId", - "clientId", - "deviceIdentitySha256Base64url", - "nonce", - "targetOrigin", - "targetPath", - "type" - ], - "title": "RemoteControlClientEnrollmentDeviceKeySignPayload", - "type": "object" - } - ] - }, "DynamicToolSpec": { "properties": { "deferLoading": { @@ -1453,6 +1259,11 @@ "array", "null" ] + }, + "requestAttestation": { + "default": false, + "description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.", + "type": "boolean" } }, "type": "object" @@ -2144,6 +1955,14 @@ ], "type": "object" }, + "PluginListMarketplaceKind": { + "enum": [ + "local", + "workspace-directory", + "shared-with-me" + ], + "type": "string" + }, "PluginListParams": { "properties": { "cwds": { @@ -2155,6 +1974,16 @@ "array", "null" ] + }, + "marketplaceKinds": { + "description": "Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus the default remote catalog when enabled by feature flag.", + "items": { + "$ref": "#/definitions/PluginListMarketplaceKind" + }, + "type": [ + "array", + "null" + ] } }, "type": "object" @@ -2197,11 +2026,37 @@ ], "type": "object" }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, "PluginShareListParams": { "type": "object" }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, "PluginShareSaveParams": { "properties": { + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "pluginPath": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -2210,6 +2065,15 @@ "string", "null" ] + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": [ + "array", + "null" + ] } }, "required": [ @@ -2217,6 +2081,61 @@ ], "type": "object" }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginShareTargetRole" + } + }, + "required": [ + "principalId", + "principalType", + "role" + ], + "type": "object" + }, + "PluginShareTargetRole": { + "enum": [ + "reader", + "editor" + ], + "type": "string" + }, + "PluginShareUpdateDiscoverability": { + "enum": [ + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, + "PluginShareUpdateTargetsParams": { + "properties": { + "discoverability": { + "$ref": "#/definitions/PluginShareUpdateDiscoverability" + }, + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": "array" + } + }, + "required": [ + "discoverability", + "remotePluginId", + "shareTargets" + ], + "type": "object" + }, "PluginSkillReadParams": { "properties": { "remoteMarketplaceName": { @@ -2265,6 +2184,28 @@ ], "type": "object" }, + "ProcessTerminalSize": { + "description": "PTY size in character cells for `process/spawn` PTY sessions.", + "properties": { + "cols": { + "description": "Terminal width in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "rows": { + "description": "Terminal height in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "cols", + "rows" + ], + "type": "object" + }, "RealtimeOutputModality": { "enum": [ "text", @@ -2396,20 +2337,6 @@ } ] }, - "RemoteControlClientConnectionAudience": { - "description": "Audience for a remote-control client connection device-key proof.", - "enum": [ - "remote_control_client_websocket" - ], - "type": "string" - }, - "RemoteControlClientEnrollmentAudience": { - "description": "Audience for a remote-control client enrollment device-key proof.", - "enum": [ - "remote_control_client_enrollment" - ], - "type": "string" - }, "RequestId": { "anyOf": [ { @@ -2850,6 +2777,28 @@ "title": "CompactionResponseItem", "type": "object" }, + { + "properties": { + "encrypted_content": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "context_compaction" + ], + "title": "ContextCompactionResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ContextCompactionResponseItem", + "type": "object" + }, { "properties": { "type": { @@ -3212,13 +3161,6 @@ ], "type": "object" }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionMigration": { "properties": { "cwd": { @@ -3297,24 +3239,6 @@ ], "type": "object" }, - "SkillsListExtraRootsForCwd": { - "properties": { - "cwd": { - "type": "string" - }, - "extraUserRoots": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "cwd", - "extraUserRoots" - ], - "type": "object" - }, "SkillsListParams": { "properties": { "cwds": { @@ -3327,17 +3251,6 @@ "forceReload": { "description": "When true, bypass the skills cache and re-scan skills from disk.", "type": "boolean" - }, - "perCwdExtraUserRoots": { - "default": null, - "description": "Optional per-cwd extra roots to scan as user-scoped skills.", - "items": { - "$ref": "#/definitions/SkillsListExtraRootsForCwd" - }, - "type": [ - "array", - "null" - ] } }, "type": "object" @@ -3496,24 +3409,24 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { "type": "string" + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this forked thread." } }, "required": [ @@ -3907,20 +3820,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { @@ -3988,6 +3890,14 @@ ], "type": "string" }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadSourceKind": { "enum": [ "cli", @@ -4096,20 +4006,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sessionStartSource": { @@ -4121,6 +4020,17 @@ "type": "null" } ] + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this thread." } }, "type": "object" @@ -4184,6 +4094,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStartParams": { "properties": { "approvalPolicy": { @@ -4265,22 +4200,11 @@ "description": "Override the sandbox policy for this turn and subsequent turns." }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } - ], - "description": "Override the service tier for this turn and subsequent turns." + "description": "Override the service tier for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] }, "summary": { "anyOf": [ @@ -5103,6 +5027,30 @@ "title": "Plugin/share/saveRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/share/updateTargets" + ], + "title": "Plugin/share/updateTargetsRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginShareUpdateTargetsParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/updateTargetsRequest", + "type": "object" + }, { "properties": { "id": { @@ -5175,78 +5123,6 @@ "title": "App/listRequest", "type": "object" }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "device/key/create" - ], - "title": "Device/key/createRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/DeviceKeyCreateParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Device/key/createRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "device/key/public" - ], - "title": "Device/key/publicRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/DeviceKeyPublicParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Device/key/publicRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "device/key/sign" - ], - "title": "Device/key/signRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/DeviceKeySignParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Device/key/signRequest", - "type": "object" - }, { "properties": { "id": { @@ -5870,6 +5746,29 @@ "title": "WindowsSandbox/setupStartRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "windowsSandbox/readiness" + ], + "title": "WindowsSandbox/readinessRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "WindowsSandbox/readinessRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index ce587a7f10..5b6c4cd185 100644 --- a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json @@ -593,6 +593,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -602,6 +607,7 @@ }, "required": [ "itemId", + "startedAtMs", "threadId", "turnId" ], diff --git a/codex-rs/app-server-protocol/schema/json/FileChangeRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/FileChangeRequestApprovalParams.json index f52e98cd0d..f17388aa53 100644 --- a/codex-rs/app-server-protocol/schema/json/FileChangeRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/FileChangeRequestApprovalParams.json @@ -18,6 +18,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -27,6 +32,7 @@ }, "required": [ "itemId", + "startedAtMs", "threadId", "turnId" ], diff --git a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json index adb50dee43..1383da6124 100644 --- a/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json +++ b/codex-rs/app-server-protocol/schema/json/PermissionsRequestApprovalParams.json @@ -297,6 +297,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -308,6 +313,7 @@ "cwd", "itemId", "permissions", + "startedAtMs", "threadId", "turnId" ], diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 82914f3a6f..6af19f89e2 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1736,6 +1736,8 @@ "preToolUse", "permissionRequest", "postToolUse", + "preCompact", + "postCompact", "sessionStart", "userPromptSubmit", "stop" @@ -1932,6 +1934,11 @@ }, "ItemCompletedNotification": { "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle completed.", + "format": "int64", + "type": "integer" + }, "item": { "$ref": "#/definitions/ThreadItem" }, @@ -1943,6 +1950,7 @@ } }, "required": [ + "completedAtMs", "item", "threadId", "turnId" @@ -1955,6 +1963,11 @@ "action": { "$ref": "#/definitions/GuardianApprovalReviewAction" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review completed.", + "format": "int64", + "type": "integer" + }, "decisionSource": { "$ref": "#/definitions/AutoReviewDecisionSource" }, @@ -1965,6 +1978,11 @@ "description": "Stable identifier for this review.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, "targetItemId": { "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", "type": [ @@ -1981,9 +1999,11 @@ }, "required": [ "action", + "completedAtMs", "decisionSource", "review", "reviewId", + "startedAtMs", "threadId", "turnId" ], @@ -2002,6 +2022,11 @@ "description": "Stable identifier for this review.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, "targetItemId": { "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", "type": [ @@ -2020,6 +2045,7 @@ "action", "review", "reviewId", + "startedAtMs", "threadId", "turnId" ], @@ -2030,6 +2056,11 @@ "item": { "$ref": "#/definitions/ThreadItem" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -2039,6 +2070,7 @@ }, "required": [ "item", + "startedAtMs", "threadId", "turnId" ], @@ -2403,6 +2435,96 @@ ], "type": "string" }, + "ProcessExitedNotification": { + "description": "Final process exit notification for `process/spawn`.", + "properties": { + "exitCode": { + "description": "Process exit code.", + "format": "int32", + "type": "integer" + }, + "processHandle": { + "description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.", + "type": "string" + }, + "stderr": { + "description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `process/outputDelta`.", + "type": "string" + }, + "stderrCapReached": { + "description": "Whether stderr reached `outputBytesCap`.\n\nIn streaming mode, stderr is empty and cap state is also reported on the final stderr `process/outputDelta` notification.", + "type": "boolean" + }, + "stdout": { + "description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `process/outputDelta`.", + "type": "string" + }, + "stdoutCapReached": { + "description": "Whether stdout reached `outputBytesCap`.\n\nIn streaming mode, stdout is empty and cap state is also reported on the final stdout `process/outputDelta` notification.", + "type": "boolean" + } + }, + "required": [ + "exitCode", + "processHandle", + "stderr", + "stderrCapReached", + "stdout", + "stdoutCapReached" + ], + "type": "object" + }, + "ProcessOutputDeltaNotification": { + "description": "Base64-encoded output chunk emitted for a streaming `process/spawn` request.", + "properties": { + "capReached": { + "description": "True on the final streamed chunk for this stream when output was truncated by `outputBytesCap`.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Base64-encoded output bytes.", + "type": "string" + }, + "processHandle": { + "description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.", + "type": "string" + }, + "stream": { + "allOf": [ + { + "$ref": "#/definitions/ProcessOutputStream" + } + ], + "description": "Output stream this chunk belongs to." + } + }, + "required": [ + "capReached", + "deltaBase64", + "processHandle", + "stream" + ], + "type": "object" + }, + "ProcessOutputStream": { + "description": "Stream label for `process/outputDelta` notifications.", + "oneOf": [ + { + "description": "stdout stream. PTY mode multiplexes terminal output here.", + "enum": [ + "stdout" + ], + "type": "string" + }, + { + "description": "stderr stream.", + "enum": [ + "stderr" + ], + "type": "string" + } + ] + }, "RateLimitReachedType": { "enum": [ "rate_limit_reached", @@ -2615,7 +2737,7 @@ "type": "string" }, "RemoteControlStatusChangedNotification": { - "description": "Current remote-control connection status and environment id exposed to clients.", + "description": "Current remote-control connection status and remote identity exposed to clients.", "properties": { "environmentId": { "type": [ @@ -2623,11 +2745,15 @@ "null" ] }, + "installationId": { + "type": "string" + }, "status": { "$ref": "#/definitions/RemoteControlConnectionStatus" } }, "required": [ + "installationId", "status" ], "type": "object" @@ -2970,6 +3096,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -2986,6 +3116,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -3007,6 +3148,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -3992,6 +4134,14 @@ ], "type": "object" }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStartedNotification": { "properties": { "thread": { @@ -4210,12 +4360,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -4298,6 +4457,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnPlanStep": { "properties": { "status": { @@ -5151,6 +5335,48 @@ "title": "Command/exec/outputDeltaNotification", "type": "object" }, + { + "description": "Stream base64-encoded stdout/stderr chunks for a running `process/spawn` session.", + "properties": { + "method": { + "enum": [ + "process/outputDelta" + ], + "title": "Process/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ProcessOutputDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Process/outputDeltaNotification", + "type": "object" + }, + { + "description": "Final exit notification for a `process/spawn` session.", + "properties": { + "method": { + "enum": [ + "process/exited" + ], + "title": "Process/exitedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ProcessExitedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Process/exitedNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 51cab50810..697cb22e92 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -121,6 +121,9 @@ ], "type": "object" }, + "AttestationGenerateParams": { + "type": "object" + }, "ChatgptAuthTokensRefreshParams": { "properties": { "previousAccountId": { @@ -417,6 +420,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -426,6 +434,7 @@ }, "required": [ "itemId", + "startedAtMs", "threadId", "turnId" ], @@ -598,6 +607,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -607,6 +621,7 @@ }, "required": [ "itemId", + "startedAtMs", "threadId", "turnId" ], @@ -1587,6 +1602,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -1598,6 +1618,7 @@ "cwd", "itemId", "permissions", + "startedAtMs", "threadId", "turnId" ], @@ -1900,6 +1921,31 @@ "title": "Account/chatgptAuthTokens/refreshRequest", "type": "object" }, + { + "description": "Generate a fresh upstream attestation result on demand.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "attestation/generate" + ], + "title": "Attestation/generateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AttestationGenerateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Attestation/generateRequest", + "type": "object" + }, { "description": "DEPRECATED APIs below Request to approve a patch. This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).", "properties": { 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 f856b43d66..fc75cd8465 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 @@ -83,6 +83,25 @@ "title": "ApplyPatchApprovalResponse", "type": "object" }, + "AttestationGenerateParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AttestationGenerateParams", + "type": "object" + }, + "AttestationGenerateResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "token": { + "description": "Opaque client attestation token.", + "type": "string" + } + }, + "required": [ + "token" + ], + "title": "AttestationGenerateResponse", + "type": "object" + }, "ChatgptAuthTokensRefreshParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -810,6 +829,30 @@ "title": "Plugin/share/saveRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "plugin/share/updateTargets" + ], + "title": "Plugin/share/updateTargetsRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/PluginShareUpdateTargetsParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/updateTargetsRequest", + "type": "object" + }, { "properties": { "id": { @@ -882,78 +925,6 @@ "title": "App/listRequest", "type": "object" }, - { - "properties": { - "id": { - "$ref": "#/definitions/v2/RequestId" - }, - "method": { - "enum": [ - "device/key/create" - ], - "title": "Device/key/createRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/DeviceKeyCreateParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Device/key/createRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/v2/RequestId" - }, - "method": { - "enum": [ - "device/key/public" - ], - "title": "Device/key/publicRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/DeviceKeyPublicParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Device/key/publicRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/v2/RequestId" - }, - "method": { - "enum": [ - "device/key/sign" - ], - "title": "Device/key/signRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/v2/DeviceKeySignParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Device/key/signRequest", - "type": "object" - }, { "properties": { "id": { @@ -1577,6 +1548,29 @@ "title": "WindowsSandbox/setupStartRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "windowsSandbox/readiness" + ], + "title": "WindowsSandbox/readinessRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "WindowsSandbox/readinessRequest", + "type": "object" + }, { "properties": { "id": { @@ -2171,6 +2165,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -2180,6 +2179,7 @@ }, "required": [ "itemId", + "startedAtMs", "threadId", "turnId" ], @@ -2436,6 +2436,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -2445,6 +2450,7 @@ }, "required": [ "itemId", + "startedAtMs", "threadId", "turnId" ], @@ -2633,6 +2639,11 @@ "array", "null" ] + }, + "requestAttestation": { + "default": false, + "description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.", + "type": "boolean" } }, "type": "object" @@ -3616,6 +3627,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -3627,6 +3643,7 @@ "cwd", "itemId", "permissions", + "startedAtMs", "threadId", "turnId" ], @@ -4248,6 +4265,48 @@ "title": "Command/exec/outputDeltaNotification", "type": "object" }, + { + "description": "Stream base64-encoded stdout/stderr chunks for a running `process/spawn` session.", + "properties": { + "method": { + "enum": [ + "process/outputDelta" + ], + "title": "Process/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ProcessOutputDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Process/outputDeltaNotification", + "type": "object" + }, + { + "description": "Final exit notification for a `process/spawn` session.", + "properties": { + "method": { + "enum": [ + "process/exited" + ], + "title": "Process/exitedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ProcessExitedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Process/exitedNotification", + "type": "object" + }, { "properties": { "method": { @@ -5172,6 +5231,31 @@ "title": "Account/chatgptAuthTokens/refreshRequest", "type": "object" }, + { + "description": "Generate a fresh upstream attestation result on demand.", + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "attestation/generate" + ], + "title": "Attestation/generateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AttestationGenerateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Attestation/generateRequest", + "type": "object" + }, { "description": "DEPRECATED APIs below Request to approve a patch. This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).", "properties": { @@ -7135,13 +7219,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -7460,6 +7540,12 @@ }, "ConfigRequirements": { "properties": { + "allowManagedHooksOnly": { + "type": [ + "boolean", + "null" + ] + }, "allowedApprovalPolicies": { "items": { "$ref": "#/definitions/v2/AskForApproval" @@ -7644,6 +7730,12 @@ "command": { "type": "string" }, + "commandWindows": { + "type": [ + "string", + "null" + ] + }, "statusMessage": { "type": [ "string", @@ -7862,300 +7954,6 @@ "title": "DeprecationNoticeNotification", "type": "object" }, - "DeviceKeyAlgorithm": { - "description": "Device-key algorithm reported at enrollment and signing boundaries.", - "enum": [ - "ecdsa_p256_sha256" - ], - "type": "string" - }, - "DeviceKeyCreateParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Create a controller-local device key with a random key id.", - "properties": { - "accountUserId": { - "type": "string" - }, - "clientId": { - "type": "string" - }, - "protectionPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/v2/DeviceKeyProtectionPolicy" - }, - { - "type": "null" - } - ], - "description": "Defaults to `hardware_only` when omitted." - } - }, - "required": [ - "accountUserId", - "clientId" - ], - "title": "DeviceKeyCreateParams", - "type": "object" - }, - "DeviceKeyCreateResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Device-key metadata and public key returned by create/public APIs.", - "properties": { - "algorithm": { - "$ref": "#/definitions/v2/DeviceKeyAlgorithm" - }, - "keyId": { - "type": "string" - }, - "protectionClass": { - "$ref": "#/definitions/v2/DeviceKeyProtectionClass" - }, - "publicKeySpkiDerBase64": { - "description": "SubjectPublicKeyInfo DER encoded as base64.", - "type": "string" - } - }, - "required": [ - "algorithm", - "keyId", - "protectionClass", - "publicKeySpkiDerBase64" - ], - "title": "DeviceKeyCreateResponse", - "type": "object" - }, - "DeviceKeyProtectionClass": { - "description": "Platform protection class for a controller-local device key.", - "enum": [ - "hardware_secure_enclave", - "hardware_tpm", - "os_protected_nonextractable" - ], - "type": "string" - }, - "DeviceKeyProtectionPolicy": { - "description": "Protection policy for creating or loading a controller-local device key.", - "enum": [ - "hardware_only", - "allow_os_protected_nonextractable" - ], - "type": "string" - }, - "DeviceKeyPublicParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Fetch a controller-local device key public key by id.", - "properties": { - "keyId": { - "type": "string" - } - }, - "required": [ - "keyId" - ], - "title": "DeviceKeyPublicParams", - "type": "object" - }, - "DeviceKeyPublicResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Device-key public metadata returned by `device/key/public`.", - "properties": { - "algorithm": { - "$ref": "#/definitions/v2/DeviceKeyAlgorithm" - }, - "keyId": { - "type": "string" - }, - "protectionClass": { - "$ref": "#/definitions/v2/DeviceKeyProtectionClass" - }, - "publicKeySpkiDerBase64": { - "description": "SubjectPublicKeyInfo DER encoded as base64.", - "type": "string" - } - }, - "required": [ - "algorithm", - "keyId", - "protectionClass", - "publicKeySpkiDerBase64" - ], - "title": "DeviceKeyPublicResponse", - "type": "object" - }, - "DeviceKeySignParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Sign an accepted structured payload with a controller-local device key.", - "properties": { - "keyId": { - "type": "string" - }, - "payload": { - "$ref": "#/definitions/v2/DeviceKeySignPayload" - } - }, - "required": [ - "keyId", - "payload" - ], - "title": "DeviceKeySignParams", - "type": "object" - }, - "DeviceKeySignPayload": { - "description": "Structured payloads accepted by `device/key/sign`.", - "oneOf": [ - { - "description": "Payload bound to one remote-control controller websocket `/client` connection challenge.", - "properties": { - "accountUserId": { - "type": "string" - }, - "audience": { - "$ref": "#/definitions/v2/RemoteControlClientConnectionAudience" - }, - "clientId": { - "type": "string" - }, - "nonce": { - "type": "string" - }, - "scopes": { - "description": "Must contain exactly `remote_control_controller_websocket`.", - "items": { - "type": "string" - }, - "type": "array" - }, - "sessionId": { - "description": "Backend-issued websocket session id that this proof authorizes.", - "type": "string" - }, - "targetOrigin": { - "description": "Origin of the backend endpoint that issued the challenge and will verify this proof.", - "type": "string" - }, - "targetPath": { - "description": "Websocket route path that this proof authorizes.", - "type": "string" - }, - "tokenExpiresAt": { - "description": "Remote-control token expiration as Unix seconds.", - "format": "int64", - "type": "integer" - }, - "tokenSha256Base64url": { - "description": "SHA-256 of the controller-scoped remote-control token, encoded as unpadded base64url.", - "type": "string" - }, - "type": { - "enum": [ - "remoteControlClientConnection" - ], - "title": "RemoteControlClientConnectionDeviceKeySignPayloadType", - "type": "string" - } - }, - "required": [ - "accountUserId", - "audience", - "clientId", - "nonce", - "scopes", - "sessionId", - "targetOrigin", - "targetPath", - "tokenExpiresAt", - "tokenSha256Base64url", - "type" - ], - "title": "RemoteControlClientConnectionDeviceKeySignPayload", - "type": "object" - }, - { - "description": "Payload bound to a remote-control client `/client/enroll` ownership challenge.", - "properties": { - "accountUserId": { - "type": "string" - }, - "audience": { - "$ref": "#/definitions/v2/RemoteControlClientEnrollmentAudience" - }, - "challengeExpiresAt": { - "description": "Enrollment challenge expiration as Unix seconds.", - "format": "int64", - "type": "integer" - }, - "challengeId": { - "description": "Backend-issued enrollment challenge id that this proof authorizes.", - "type": "string" - }, - "clientId": { - "type": "string" - }, - "deviceIdentitySha256Base64url": { - "description": "SHA-256 of the requested device identity operation, encoded as unpadded base64url.", - "type": "string" - }, - "nonce": { - "type": "string" - }, - "targetOrigin": { - "description": "Origin of the backend endpoint that issued the challenge and will verify this proof.", - "type": "string" - }, - "targetPath": { - "description": "HTTP route path that this proof authorizes.", - "type": "string" - }, - "type": { - "enum": [ - "remoteControlClientEnrollment" - ], - "title": "RemoteControlClientEnrollmentDeviceKeySignPayloadType", - "type": "string" - } - }, - "required": [ - "accountUserId", - "audience", - "challengeExpiresAt", - "challengeId", - "clientId", - "deviceIdentitySha256Base64url", - "nonce", - "targetOrigin", - "targetPath", - "type" - ], - "title": "RemoteControlClientEnrollmentDeviceKeySignPayload", - "type": "object" - } - ] - }, - "DeviceKeySignResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "ASN.1 DER signature returned by `device/key/sign`.", - "properties": { - "algorithm": { - "$ref": "#/definitions/v2/DeviceKeyAlgorithm" - }, - "signatureDerBase64": { - "description": "ECDSA signature DER encoded as base64.", - "type": "string" - }, - "signedPayloadBase64": { - "description": "Exact bytes signed by the device key, encoded as base64. Verifiers must verify this byte string directly and must not reserialize `payload`.", - "type": "string" - } - }, - "required": [ - "algorithm", - "signatureDerBase64", - "signedPayloadBase64" - ], - "title": "DeviceKeySignResponse", - "type": "object" - }, "DynamicToolCallOutputContentItem": { "oneOf": [ { @@ -9739,6 +9537,8 @@ "preToolUse", "permissionRequest", "postToolUse", + "preCompact", + "postCompact", "sessionStart", "userPromptSubmit", "stop" @@ -9768,6 +9568,9 @@ "null" ] }, + "currentHash": { + "type": "string" + }, "displayOrder": { "format": "int64", "type": "integer" @@ -9815,9 +9618,13 @@ "format": "uint64", "minimum": 0.0, "type": "integer" + }, + "trustStatus": { + "$ref": "#/definitions/v2/HookTrustStatus" } }, "required": [ + "currentHash", "displayOrder", "enabled", "eventName", @@ -9826,7 +9633,8 @@ "key", "source", "sourcePath", - "timeoutSec" + "timeoutSec", + "trustStatus" ], "type": "object" }, @@ -10016,6 +9824,15 @@ "title": "HookStartedNotification", "type": "object" }, + "HookTrustStatus": { + "enum": [ + "managed", + "untrusted", + "trusted", + "modified" + ], + "type": "string" + }, "HooksListEntry": { "properties": { "cwd": { @@ -10109,6 +9926,11 @@ "ItemCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle completed.", + "format": "int64", + "type": "integer" + }, "item": { "$ref": "#/definitions/v2/ThreadItem" }, @@ -10120,6 +9942,7 @@ } }, "required": [ + "completedAtMs", "item", "threadId", "turnId" @@ -10134,6 +9957,11 @@ "action": { "$ref": "#/definitions/v2/GuardianApprovalReviewAction" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review completed.", + "format": "int64", + "type": "integer" + }, "decisionSource": { "$ref": "#/definitions/v2/AutoReviewDecisionSource" }, @@ -10144,6 +9972,11 @@ "description": "Stable identifier for this review.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, "targetItemId": { "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", "type": [ @@ -10160,9 +9993,11 @@ }, "required": [ "action", + "completedAtMs", "decisionSource", "review", "reviewId", + "startedAtMs", "threadId", "turnId" ], @@ -10183,6 +10018,11 @@ "description": "Stable identifier for this review.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, "targetItemId": { "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", "type": [ @@ -10201,6 +10041,7 @@ "action", "review", "reviewId", + "startedAtMs", "threadId", "turnId" ], @@ -10213,6 +10054,11 @@ "item": { "$ref": "#/definitions/v2/ThreadItem" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -10222,6 +10068,7 @@ }, "required": [ "item", + "startedAtMs", "threadId", "turnId" ], @@ -10550,12 +10397,24 @@ }, "type": "array" }, + "PostCompact": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "PostToolUse": { "items": { "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" }, "type": "array" }, + "PreCompact": { + "items": { + "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "PreToolUse": { "items": { "$ref": "#/definitions/v2/ConfiguredHookMatcherGroup" @@ -10595,7 +10454,9 @@ }, "required": [ "PermissionRequest", + "PostCompact", "PostToolUse", + "PreCompact", "PreToolUse", "SessionStart", "Stop", @@ -11212,6 +11073,7 @@ "properties": { "additionalSpeedTiers": { "default": [], + "description": "Deprecated: use `serviceTiers` instead.", "items": { "type": "string" }, @@ -11258,6 +11120,13 @@ "model": { "type": "string" }, + "serviceTiers": { + "default": [], + "items": { + "$ref": "#/definitions/v2/ModelServiceTier" + }, + "type": "array" + }, "supportedReasoningEfforts": { "items": { "$ref": "#/definitions/v2/ReasoningEffortOption" @@ -11422,6 +11291,25 @@ "title": "ModelReroutedNotification", "type": "object" }, + "ModelServiceTier": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name" + ], + "type": "object" + }, "ModelUpgradeInfo": { "properties": { "migrationMarkdown": { @@ -11987,6 +11875,12 @@ "null" ] }, + "hooks": { + "items": { + "$ref": "#/definitions/v2/PluginHookSummary" + }, + "type": "array" + }, "marketplaceName": { "type": "string" }, @@ -12018,6 +11912,7 @@ }, "required": [ "apps", + "hooks", "marketplaceName", "mcpServers", "skills", @@ -12025,6 +11920,21 @@ ], "type": "object" }, + "PluginHookSummary": { + "properties": { + "eventName": { + "$ref": "#/definitions/v2/HookEventName" + }, + "key": { + "type": "string" + } + }, + "required": [ + "eventName", + "key" + ], + "type": "object" + }, "PluginInstallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12212,6 +12122,14 @@ ], "type": "object" }, + "PluginListMarketplaceKind": { + "enum": [ + "local", + "workspace-directory", + "shared-with-me" + ], + "type": "string" + }, "PluginListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12224,6 +12142,16 @@ "array", "null" ] + }, + "marketplaceKinds": { + "description": "Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus the default remote catalog when enabled by feature flag.", + "items": { + "$ref": "#/definitions/v2/PluginListMarketplaceKind" + }, + "type": [ + "array", + "null" + ] } }, "title": "PluginListParams", @@ -12340,6 +12268,62 @@ "title": "PluginReadResponse", "type": "object" }, + "PluginShareContext": { + "properties": { + "creatorAccountUserId": { + "type": [ + "string", + "null" + ] + }, + "creatorName": { + "type": [ + "string", + "null" + ] + }, + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, + "remotePluginId": { + "type": "string" + }, + "remoteVersion": { + "default": null, + "description": "Version of the remote shared plugin release when available.", + "type": [ + "string", + "null" + ] + }, + "sharePrincipals": { + "items": { + "$ref": "#/definitions/v2/PluginSharePrincipal" + }, + "type": [ + "array", + "null" + ] + }, + "shareUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "remotePluginId" + ], + "type": "object" + }, "PluginShareDeleteParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12358,6 +12342,14 @@ "title": "PluginShareDeleteResponse", "type": "object" }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, "PluginShareListItem": { "properties": { "localPluginPath": { @@ -12372,14 +12364,10 @@ }, "plugin": { "$ref": "#/definitions/v2/PluginSummary" - }, - "shareUrl": { - "type": "string" } }, "required": [ - "plugin", - "shareUrl" + "plugin" ], "type": "object" }, @@ -12404,9 +12392,58 @@ "title": "PluginShareListResponse", "type": "object" }, + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/v2/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/v2/PluginSharePrincipalRole" + } + }, + "required": [ + "name", + "principalId", + "principalType", + "role" + ], + "type": "object" + }, + "PluginSharePrincipalRole": { + "enum": [ + "reader", + "editor", + "owner" + ], + "type": "string" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, "PluginShareSaveParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "pluginPath": { "$ref": "#/definitions/v2/AbsolutePathBuf" }, @@ -12415,6 +12452,15 @@ "string", "null" ] + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/v2/PluginShareTarget" + }, + "type": [ + "array", + "null" + ] } }, "required": [ @@ -12440,6 +12486,83 @@ "title": "PluginShareSaveResponse", "type": "object" }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/v2/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/v2/PluginShareTargetRole" + } + }, + "required": [ + "principalId", + "principalType", + "role" + ], + "type": "object" + }, + "PluginShareTargetRole": { + "enum": [ + "reader", + "editor" + ], + "type": "string" + }, + "PluginShareUpdateDiscoverability": { + "enum": [ + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, + "PluginShareUpdateTargetsParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "discoverability": { + "$ref": "#/definitions/v2/PluginShareUpdateDiscoverability" + }, + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/v2/PluginShareTarget" + }, + "type": "array" + } + }, + "required": [ + "discoverability", + "remotePluginId", + "shareTargets" + ], + "title": "PluginShareUpdateTargetsParams", + "type": "object" + }, + "PluginShareUpdateTargetsResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "discoverability": { + "$ref": "#/definitions/v2/PluginShareDiscoverability" + }, + "principals": { + "items": { + "$ref": "#/definitions/v2/PluginSharePrincipal" + }, + "type": "array" + } + }, + "required": [ + "discoverability", + "principals" + ], + "title": "PluginShareUpdateTargetsResponse", + "type": "object" + }, "PluginSkillReadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12589,9 +12712,42 @@ } ] }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "localVersion": { + "default": null, + "description": "Version of the locally materialized plugin package when available.", + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, + "remotePluginId": { + "description": "Backend remote plugin identifier when available.", + "type": [ + "string", + "null" + ] + }, + "shareContext": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PluginShareContext" + }, + { + "type": "null" + } + ], + "description": "Remote sharing context associated with this plugin when available." + }, "source": { "$ref": "#/definitions/v2/PluginSource" } @@ -12643,6 +12799,122 @@ ], "type": "object" }, + "ProcessExitedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Final process exit notification for `process/spawn`.", + "properties": { + "exitCode": { + "description": "Process exit code.", + "format": "int32", + "type": "integer" + }, + "processHandle": { + "description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.", + "type": "string" + }, + "stderr": { + "description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `process/outputDelta`.", + "type": "string" + }, + "stderrCapReached": { + "description": "Whether stderr reached `outputBytesCap`.\n\nIn streaming mode, stderr is empty and cap state is also reported on the final stderr `process/outputDelta` notification.", + "type": "boolean" + }, + "stdout": { + "description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `process/outputDelta`.", + "type": "string" + }, + "stdoutCapReached": { + "description": "Whether stdout reached `outputBytesCap`.\n\nIn streaming mode, stdout is empty and cap state is also reported on the final stdout `process/outputDelta` notification.", + "type": "boolean" + } + }, + "required": [ + "exitCode", + "processHandle", + "stderr", + "stderrCapReached", + "stdout", + "stdoutCapReached" + ], + "title": "ProcessExitedNotification", + "type": "object" + }, + "ProcessOutputDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Base64-encoded output chunk emitted for a streaming `process/spawn` request.", + "properties": { + "capReached": { + "description": "True on the final streamed chunk for this stream when output was truncated by `outputBytesCap`.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Base64-encoded output bytes.", + "type": "string" + }, + "processHandle": { + "description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.", + "type": "string" + }, + "stream": { + "allOf": [ + { + "$ref": "#/definitions/v2/ProcessOutputStream" + } + ], + "description": "Output stream this chunk belongs to." + } + }, + "required": [ + "capReached", + "deltaBase64", + "processHandle", + "stream" + ], + "title": "ProcessOutputDeltaNotification", + "type": "object" + }, + "ProcessOutputStream": { + "description": "Stream label for `process/outputDelta` notifications.", + "oneOf": [ + { + "description": "stdout stream. PTY mode multiplexes terminal output here.", + "enum": [ + "stdout" + ], + "type": "string" + }, + { + "description": "stderr stream.", + "enum": [ + "stderr" + ], + "type": "string" + } + ] + }, + "ProcessTerminalSize": { + "description": "PTY size in character cells for `process/spawn` PTY sessions.", + "properties": { + "cols": { + "description": "Terminal width in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "rows": { + "description": "Terminal height in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "cols", + "rows" + ], + "type": "object" + }, "ProfileV2": { "additionalProperties": true, "properties": { @@ -12716,13 +12988,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -13140,20 +13408,6 @@ "title": "ReasoningTextDeltaNotification", "type": "object" }, - "RemoteControlClientConnectionAudience": { - "description": "Audience for a remote-control client connection device-key proof.", - "enum": [ - "remote_control_client_websocket" - ], - "type": "string" - }, - "RemoteControlClientEnrollmentAudience": { - "description": "Audience for a remote-control client enrollment device-key proof.", - "enum": [ - "remote_control_client_enrollment" - ], - "type": "string" - }, "RemoteControlConnectionStatus": { "enum": [ "disabled", @@ -13165,7 +13419,7 @@ }, "RemoteControlStatusChangedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Current remote-control connection status and environment id exposed to clients.", + "description": "Current remote-control connection status and remote identity exposed to clients.", "properties": { "environmentId": { "type": [ @@ -13173,11 +13427,15 @@ "null" ] }, + "installationId": { + "type": "string" + }, "status": { "$ref": "#/definitions/v2/RemoteControlConnectionStatus" } }, "required": [ + "installationId", "status" ], "title": "RemoteControlStatusChangedNotification", @@ -13791,6 +14049,28 @@ "title": "CompactionResponseItem", "type": "object" }, + { + "properties": { + "encrypted_content": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "context_compaction" + ], + "title": "ContextCompactionResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ContextCompactionResponseItem", + "type": "object" + }, { "properties": { "type": { @@ -14229,13 +14509,6 @@ "title": "ServerRequestResolvedNotification", "type": "object" }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionMigration": { "properties": { "cwd": { @@ -14625,24 +14898,6 @@ ], "type": "object" }, - "SkillsListExtraRootsForCwd": { - "properties": { - "cwd": { - "type": "string" - }, - "extraUserRoots": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "cwd", - "extraUserRoots" - ], - "type": "object" - }, "SkillsListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14656,17 +14911,6 @@ "forceReload": { "description": "When true, bypass the skills cache and re-scan skills from disk.", "type": "boolean" - }, - "perCwdExtraUserRoots": { - "default": null, - "description": "Optional per-cwd extra roots to scan as user-scoped skills.", - "items": { - "$ref": "#/definitions/v2/SkillsListExtraRootsForCwd" - }, - "type": [ - "array", - "null" - ] } }, "title": "SkillsListParams", @@ -14950,6 +15194,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -14966,6 +15214,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -14987,6 +15246,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -15162,24 +15422,24 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { "type": "string" + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this forked thread." } }, "required": [ @@ -15238,13 +15498,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -16669,20 +16925,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { @@ -16745,13 +16990,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -16860,6 +17101,14 @@ ], "type": "string" }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadSourceKind": { "enum": [ "cli", @@ -16969,20 +17218,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sessionStartSource": { @@ -16994,6 +17232,17 @@ "type": "null" } ] + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/v2/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this thread." } }, "title": "ThreadStartParams", @@ -17049,13 +17298,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -17424,12 +17669,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/v2/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/v2/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -17553,6 +17807,31 @@ "title": "TurnInterruptResponse", "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnPlanStep": { "properties": { "status": { @@ -17688,22 +17967,11 @@ "description": "Override the sandbox policy for this turn and subsequent turns." }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/v2/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } - ], - "description": "Override the service tier for this turn and subsequent turns." + "description": "Override the service tier for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] }, "summary": { "anyOf": [ @@ -18136,6 +18404,27 @@ }, "type": "object" }, + "WindowsSandboxReadiness": { + "enum": [ + "ready", + "notConfigured", + "updateRequired" + ], + "type": "string" + }, + "WindowsSandboxReadinessResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/v2/WindowsSandboxReadiness" + } + }, + "required": [ + "status" + ], + "title": "WindowsSandboxReadinessResponse", + "type": "object" + }, "WindowsSandboxSetupCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { 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 c17efe7a45..4242bc6e37 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 @@ -1569,6 +1569,30 @@ "title": "Plugin/share/saveRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "plugin/share/updateTargets" + ], + "title": "Plugin/share/updateTargetsRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PluginShareUpdateTargetsParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Plugin/share/updateTargetsRequest", + "type": "object" + }, { "properties": { "id": { @@ -1641,78 +1665,6 @@ "title": "App/listRequest", "type": "object" }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "device/key/create" - ], - "title": "Device/key/createRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/DeviceKeyCreateParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Device/key/createRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "device/key/public" - ], - "title": "Device/key/publicRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/DeviceKeyPublicParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Device/key/publicRequest", - "type": "object" - }, - { - "properties": { - "id": { - "$ref": "#/definitions/RequestId" - }, - "method": { - "enum": [ - "device/key/sign" - ], - "title": "Device/key/signRequestMethod", - "type": "string" - }, - "params": { - "$ref": "#/definitions/DeviceKeySignParams" - } - }, - "required": [ - "id", - "method", - "params" - ], - "title": "Device/key/signRequest", - "type": "object" - }, { "properties": { "id": { @@ -2336,6 +2288,29 @@ "title": "WindowsSandbox/setupStartRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "windowsSandbox/readiness" + ], + "title": "WindowsSandbox/readinessRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "WindowsSandbox/readinessRequest", + "type": "object" + }, { "properties": { "id": { @@ -3633,13 +3608,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -3958,6 +3929,12 @@ }, "ConfigRequirements": { "properties": { + "allowManagedHooksOnly": { + "type": [ + "boolean", + "null" + ] + }, "allowedApprovalPolicies": { "items": { "$ref": "#/definitions/AskForApproval" @@ -4142,6 +4119,12 @@ "command": { "type": "string" }, + "commandWindows": { + "type": [ + "string", + "null" + ] + }, "statusMessage": { "type": [ "string", @@ -4360,300 +4343,6 @@ "title": "DeprecationNoticeNotification", "type": "object" }, - "DeviceKeyAlgorithm": { - "description": "Device-key algorithm reported at enrollment and signing boundaries.", - "enum": [ - "ecdsa_p256_sha256" - ], - "type": "string" - }, - "DeviceKeyCreateParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Create a controller-local device key with a random key id.", - "properties": { - "accountUserId": { - "type": "string" - }, - "clientId": { - "type": "string" - }, - "protectionPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/DeviceKeyProtectionPolicy" - }, - { - "type": "null" - } - ], - "description": "Defaults to `hardware_only` when omitted." - } - }, - "required": [ - "accountUserId", - "clientId" - ], - "title": "DeviceKeyCreateParams", - "type": "object" - }, - "DeviceKeyCreateResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Device-key metadata and public key returned by create/public APIs.", - "properties": { - "algorithm": { - "$ref": "#/definitions/DeviceKeyAlgorithm" - }, - "keyId": { - "type": "string" - }, - "protectionClass": { - "$ref": "#/definitions/DeviceKeyProtectionClass" - }, - "publicKeySpkiDerBase64": { - "description": "SubjectPublicKeyInfo DER encoded as base64.", - "type": "string" - } - }, - "required": [ - "algorithm", - "keyId", - "protectionClass", - "publicKeySpkiDerBase64" - ], - "title": "DeviceKeyCreateResponse", - "type": "object" - }, - "DeviceKeyProtectionClass": { - "description": "Platform protection class for a controller-local device key.", - "enum": [ - "hardware_secure_enclave", - "hardware_tpm", - "os_protected_nonextractable" - ], - "type": "string" - }, - "DeviceKeyProtectionPolicy": { - "description": "Protection policy for creating or loading a controller-local device key.", - "enum": [ - "hardware_only", - "allow_os_protected_nonextractable" - ], - "type": "string" - }, - "DeviceKeyPublicParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Fetch a controller-local device key public key by id.", - "properties": { - "keyId": { - "type": "string" - } - }, - "required": [ - "keyId" - ], - "title": "DeviceKeyPublicParams", - "type": "object" - }, - "DeviceKeyPublicResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Device-key public metadata returned by `device/key/public`.", - "properties": { - "algorithm": { - "$ref": "#/definitions/DeviceKeyAlgorithm" - }, - "keyId": { - "type": "string" - }, - "protectionClass": { - "$ref": "#/definitions/DeviceKeyProtectionClass" - }, - "publicKeySpkiDerBase64": { - "description": "SubjectPublicKeyInfo DER encoded as base64.", - "type": "string" - } - }, - "required": [ - "algorithm", - "keyId", - "protectionClass", - "publicKeySpkiDerBase64" - ], - "title": "DeviceKeyPublicResponse", - "type": "object" - }, - "DeviceKeySignParams": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Sign an accepted structured payload with a controller-local device key.", - "properties": { - "keyId": { - "type": "string" - }, - "payload": { - "$ref": "#/definitions/DeviceKeySignPayload" - } - }, - "required": [ - "keyId", - "payload" - ], - "title": "DeviceKeySignParams", - "type": "object" - }, - "DeviceKeySignPayload": { - "description": "Structured payloads accepted by `device/key/sign`.", - "oneOf": [ - { - "description": "Payload bound to one remote-control controller websocket `/client` connection challenge.", - "properties": { - "accountUserId": { - "type": "string" - }, - "audience": { - "$ref": "#/definitions/RemoteControlClientConnectionAudience" - }, - "clientId": { - "type": "string" - }, - "nonce": { - "type": "string" - }, - "scopes": { - "description": "Must contain exactly `remote_control_controller_websocket`.", - "items": { - "type": "string" - }, - "type": "array" - }, - "sessionId": { - "description": "Backend-issued websocket session id that this proof authorizes.", - "type": "string" - }, - "targetOrigin": { - "description": "Origin of the backend endpoint that issued the challenge and will verify this proof.", - "type": "string" - }, - "targetPath": { - "description": "Websocket route path that this proof authorizes.", - "type": "string" - }, - "tokenExpiresAt": { - "description": "Remote-control token expiration as Unix seconds.", - "format": "int64", - "type": "integer" - }, - "tokenSha256Base64url": { - "description": "SHA-256 of the controller-scoped remote-control token, encoded as unpadded base64url.", - "type": "string" - }, - "type": { - "enum": [ - "remoteControlClientConnection" - ], - "title": "RemoteControlClientConnectionDeviceKeySignPayloadType", - "type": "string" - } - }, - "required": [ - "accountUserId", - "audience", - "clientId", - "nonce", - "scopes", - "sessionId", - "targetOrigin", - "targetPath", - "tokenExpiresAt", - "tokenSha256Base64url", - "type" - ], - "title": "RemoteControlClientConnectionDeviceKeySignPayload", - "type": "object" - }, - { - "description": "Payload bound to a remote-control client `/client/enroll` ownership challenge.", - "properties": { - "accountUserId": { - "type": "string" - }, - "audience": { - "$ref": "#/definitions/RemoteControlClientEnrollmentAudience" - }, - "challengeExpiresAt": { - "description": "Enrollment challenge expiration as Unix seconds.", - "format": "int64", - "type": "integer" - }, - "challengeId": { - "description": "Backend-issued enrollment challenge id that this proof authorizes.", - "type": "string" - }, - "clientId": { - "type": "string" - }, - "deviceIdentitySha256Base64url": { - "description": "SHA-256 of the requested device identity operation, encoded as unpadded base64url.", - "type": "string" - }, - "nonce": { - "type": "string" - }, - "targetOrigin": { - "description": "Origin of the backend endpoint that issued the challenge and will verify this proof.", - "type": "string" - }, - "targetPath": { - "description": "HTTP route path that this proof authorizes.", - "type": "string" - }, - "type": { - "enum": [ - "remoteControlClientEnrollment" - ], - "title": "RemoteControlClientEnrollmentDeviceKeySignPayloadType", - "type": "string" - } - }, - "required": [ - "accountUserId", - "audience", - "challengeExpiresAt", - "challengeId", - "clientId", - "deviceIdentitySha256Base64url", - "nonce", - "targetOrigin", - "targetPath", - "type" - ], - "title": "RemoteControlClientEnrollmentDeviceKeySignPayload", - "type": "object" - } - ] - }, - "DeviceKeySignResponse": { - "$schema": "http://json-schema.org/draft-07/schema#", - "description": "ASN.1 DER signature returned by `device/key/sign`.", - "properties": { - "algorithm": { - "$ref": "#/definitions/DeviceKeyAlgorithm" - }, - "signatureDerBase64": { - "description": "ECDSA signature DER encoded as base64.", - "type": "string" - }, - "signedPayloadBase64": { - "description": "Exact bytes signed by the device key, encoded as base64. Verifiers must verify this byte string directly and must not reserialize `payload`.", - "type": "string" - } - }, - "required": [ - "algorithm", - "signatureDerBase64", - "signedPayloadBase64" - ], - "title": "DeviceKeySignResponse", - "type": "object" - }, "DynamicToolCallOutputContentItem": { "oneOf": [ { @@ -6348,6 +6037,8 @@ "preToolUse", "permissionRequest", "postToolUse", + "preCompact", + "postCompact", "sessionStart", "userPromptSubmit", "stop" @@ -6377,6 +6068,9 @@ "null" ] }, + "currentHash": { + "type": "string" + }, "displayOrder": { "format": "int64", "type": "integer" @@ -6424,9 +6118,13 @@ "format": "uint64", "minimum": 0.0, "type": "integer" + }, + "trustStatus": { + "$ref": "#/definitions/HookTrustStatus" } }, "required": [ + "currentHash", "displayOrder", "enabled", "eventName", @@ -6435,7 +6133,8 @@ "key", "source", "sourcePath", - "timeoutSec" + "timeoutSec", + "trustStatus" ], "type": "object" }, @@ -6625,6 +6324,15 @@ "title": "HookStartedNotification", "type": "object" }, + "HookTrustStatus": { + "enum": [ + "managed", + "untrusted", + "trusted", + "modified" + ], + "type": "string" + }, "HooksListEntry": { "properties": { "cwd": { @@ -6713,6 +6421,11 @@ "array", "null" ] + }, + "requestAttestation": { + "default": false, + "description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.", + "type": "boolean" } }, "type": "object" @@ -6762,6 +6475,11 @@ "ItemCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle completed.", + "format": "int64", + "type": "integer" + }, "item": { "$ref": "#/definitions/ThreadItem" }, @@ -6773,6 +6491,7 @@ } }, "required": [ + "completedAtMs", "item", "threadId", "turnId" @@ -6787,6 +6506,11 @@ "action": { "$ref": "#/definitions/GuardianApprovalReviewAction" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review completed.", + "format": "int64", + "type": "integer" + }, "decisionSource": { "$ref": "#/definitions/AutoReviewDecisionSource" }, @@ -6797,6 +6521,11 @@ "description": "Stable identifier for this review.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, "targetItemId": { "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", "type": [ @@ -6813,9 +6542,11 @@ }, "required": [ "action", + "completedAtMs", "decisionSource", "review", "reviewId", + "startedAtMs", "threadId", "turnId" ], @@ -6836,6 +6567,11 @@ "description": "Stable identifier for this review.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, "targetItemId": { "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", "type": [ @@ -6854,6 +6590,7 @@ "action", "review", "reviewId", + "startedAtMs", "threadId", "turnId" ], @@ -6866,6 +6603,11 @@ "item": { "$ref": "#/definitions/ThreadItem" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -6875,6 +6617,7 @@ }, "required": [ "item", + "startedAtMs", "threadId", "turnId" ], @@ -7203,12 +6946,24 @@ }, "type": "array" }, + "PostCompact": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "PostToolUse": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" }, "type": "array" }, + "PreCompact": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "PreToolUse": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" @@ -7248,7 +7003,9 @@ }, "required": [ "PermissionRequest", + "PostCompact", "PostToolUse", + "PreCompact", "PreToolUse", "SessionStart", "Stop", @@ -7865,6 +7622,7 @@ "properties": { "additionalSpeedTiers": { "default": [], + "description": "Deprecated: use `serviceTiers` instead.", "items": { "type": "string" }, @@ -7911,6 +7669,13 @@ "model": { "type": "string" }, + "serviceTiers": { + "default": [], + "items": { + "$ref": "#/definitions/ModelServiceTier" + }, + "type": "array" + }, "supportedReasoningEfforts": { "items": { "$ref": "#/definitions/ReasoningEffortOption" @@ -8075,6 +7840,25 @@ "title": "ModelReroutedNotification", "type": "object" }, + "ModelServiceTier": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name" + ], + "type": "object" + }, "ModelUpgradeInfo": { "properties": { "migrationMarkdown": { @@ -8640,6 +8424,12 @@ "null" ] }, + "hooks": { + "items": { + "$ref": "#/definitions/PluginHookSummary" + }, + "type": "array" + }, "marketplaceName": { "type": "string" }, @@ -8671,6 +8461,7 @@ }, "required": [ "apps", + "hooks", "marketplaceName", "mcpServers", "skills", @@ -8678,6 +8469,21 @@ ], "type": "object" }, + "PluginHookSummary": { + "properties": { + "eventName": { + "$ref": "#/definitions/HookEventName" + }, + "key": { + "type": "string" + } + }, + "required": [ + "eventName", + "key" + ], + "type": "object" + }, "PluginInstallParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -8865,6 +8671,14 @@ ], "type": "object" }, + "PluginListMarketplaceKind": { + "enum": [ + "local", + "workspace-directory", + "shared-with-me" + ], + "type": "string" + }, "PluginListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -8877,6 +8691,16 @@ "array", "null" ] + }, + "marketplaceKinds": { + "description": "Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus the default remote catalog when enabled by feature flag.", + "items": { + "$ref": "#/definitions/PluginListMarketplaceKind" + }, + "type": [ + "array", + "null" + ] } }, "title": "PluginListParams", @@ -8993,6 +8817,62 @@ "title": "PluginReadResponse", "type": "object" }, + "PluginShareContext": { + "properties": { + "creatorAccountUserId": { + "type": [ + "string", + "null" + ] + }, + "creatorName": { + "type": [ + "string", + "null" + ] + }, + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, + "remotePluginId": { + "type": "string" + }, + "remoteVersion": { + "default": null, + "description": "Version of the remote shared plugin release when available.", + "type": [ + "string", + "null" + ] + }, + "sharePrincipals": { + "items": { + "$ref": "#/definitions/PluginSharePrincipal" + }, + "type": [ + "array", + "null" + ] + }, + "shareUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "remotePluginId" + ], + "type": "object" + }, "PluginShareDeleteParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -9011,6 +8891,14 @@ "title": "PluginShareDeleteResponse", "type": "object" }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, "PluginShareListItem": { "properties": { "localPluginPath": { @@ -9025,14 +8913,10 @@ }, "plugin": { "$ref": "#/definitions/PluginSummary" - }, - "shareUrl": { - "type": "string" } }, "required": [ - "plugin", - "shareUrl" + "plugin" ], "type": "object" }, @@ -9057,9 +8941,58 @@ "title": "PluginShareListResponse", "type": "object" }, + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginSharePrincipalRole" + } + }, + "required": [ + "name", + "principalId", + "principalType", + "role" + ], + "type": "object" + }, + "PluginSharePrincipalRole": { + "enum": [ + "reader", + "editor", + "owner" + ], + "type": "string" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, "PluginShareSaveParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "pluginPath": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -9068,6 +9001,15 @@ "string", "null" ] + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": [ + "array", + "null" + ] } }, "required": [ @@ -9093,6 +9035,83 @@ "title": "PluginShareSaveResponse", "type": "object" }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginShareTargetRole" + } + }, + "required": [ + "principalId", + "principalType", + "role" + ], + "type": "object" + }, + "PluginShareTargetRole": { + "enum": [ + "reader", + "editor" + ], + "type": "string" + }, + "PluginShareUpdateDiscoverability": { + "enum": [ + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, + "PluginShareUpdateTargetsParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "discoverability": { + "$ref": "#/definitions/PluginShareUpdateDiscoverability" + }, + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": "array" + } + }, + "required": [ + "discoverability", + "remotePluginId", + "shareTargets" + ], + "title": "PluginShareUpdateTargetsParams", + "type": "object" + }, + "PluginShareUpdateTargetsResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "discoverability": { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + "principals": { + "items": { + "$ref": "#/definitions/PluginSharePrincipal" + }, + "type": "array" + } + }, + "required": [ + "discoverability", + "principals" + ], + "title": "PluginShareUpdateTargetsResponse", + "type": "object" + }, "PluginSkillReadParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -9242,9 +9261,42 @@ } ] }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "localVersion": { + "default": null, + "description": "Version of the locally materialized plugin package when available.", + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, + "remotePluginId": { + "description": "Backend remote plugin identifier when available.", + "type": [ + "string", + "null" + ] + }, + "shareContext": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareContext" + }, + { + "type": "null" + } + ], + "description": "Remote sharing context associated with this plugin when available." + }, "source": { "$ref": "#/definitions/PluginSource" } @@ -9296,6 +9348,122 @@ ], "type": "object" }, + "ProcessExitedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Final process exit notification for `process/spawn`.", + "properties": { + "exitCode": { + "description": "Process exit code.", + "format": "int32", + "type": "integer" + }, + "processHandle": { + "description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.", + "type": "string" + }, + "stderr": { + "description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `process/outputDelta`.", + "type": "string" + }, + "stderrCapReached": { + "description": "Whether stderr reached `outputBytesCap`.\n\nIn streaming mode, stderr is empty and cap state is also reported on the final stderr `process/outputDelta` notification.", + "type": "boolean" + }, + "stdout": { + "description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `process/outputDelta`.", + "type": "string" + }, + "stdoutCapReached": { + "description": "Whether stdout reached `outputBytesCap`.\n\nIn streaming mode, stdout is empty and cap state is also reported on the final stdout `process/outputDelta` notification.", + "type": "boolean" + } + }, + "required": [ + "exitCode", + "processHandle", + "stderr", + "stderrCapReached", + "stdout", + "stdoutCapReached" + ], + "title": "ProcessExitedNotification", + "type": "object" + }, + "ProcessOutputDeltaNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Base64-encoded output chunk emitted for a streaming `process/spawn` request.", + "properties": { + "capReached": { + "description": "True on the final streamed chunk for this stream when output was truncated by `outputBytesCap`.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Base64-encoded output bytes.", + "type": "string" + }, + "processHandle": { + "description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.", + "type": "string" + }, + "stream": { + "allOf": [ + { + "$ref": "#/definitions/ProcessOutputStream" + } + ], + "description": "Output stream this chunk belongs to." + } + }, + "required": [ + "capReached", + "deltaBase64", + "processHandle", + "stream" + ], + "title": "ProcessOutputDeltaNotification", + "type": "object" + }, + "ProcessOutputStream": { + "description": "Stream label for `process/outputDelta` notifications.", + "oneOf": [ + { + "description": "stdout stream. PTY mode multiplexes terminal output here.", + "enum": [ + "stdout" + ], + "type": "string" + }, + { + "description": "stderr stream.", + "enum": [ + "stderr" + ], + "type": "string" + } + ] + }, + "ProcessTerminalSize": { + "description": "PTY size in character cells for `process/spawn` PTY sessions.", + "properties": { + "cols": { + "description": "Terminal width in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "rows": { + "description": "Terminal height in character cells.", + "format": "uint16", + "minimum": 0.0, + "type": "integer" + } + }, + "required": [ + "cols", + "rows" + ], + "type": "object" + }, "ProfileV2": { "additionalProperties": true, "properties": { @@ -9369,13 +9537,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -9793,20 +9957,6 @@ "title": "ReasoningTextDeltaNotification", "type": "object" }, - "RemoteControlClientConnectionAudience": { - "description": "Audience for a remote-control client connection device-key proof.", - "enum": [ - "remote_control_client_websocket" - ], - "type": "string" - }, - "RemoteControlClientEnrollmentAudience": { - "description": "Audience for a remote-control client enrollment device-key proof.", - "enum": [ - "remote_control_client_enrollment" - ], - "type": "string" - }, "RemoteControlConnectionStatus": { "enum": [ "disabled", @@ -9818,7 +9968,7 @@ }, "RemoteControlStatusChangedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", - "description": "Current remote-control connection status and environment id exposed to clients.", + "description": "Current remote-control connection status and remote identity exposed to clients.", "properties": { "environmentId": { "type": [ @@ -9826,11 +9976,15 @@ "null" ] }, + "installationId": { + "type": "string" + }, "status": { "$ref": "#/definitions/RemoteControlConnectionStatus" } }, "required": [ + "installationId", "status" ], "title": "RemoteControlStatusChangedNotification", @@ -10444,6 +10598,28 @@ "title": "CompactionResponseItem", "type": "object" }, + { + "properties": { + "encrypted_content": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "context_compaction" + ], + "title": "ContextCompactionResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ContextCompactionResponseItem", + "type": "object" + }, { "properties": { "type": { @@ -11352,6 +11528,48 @@ "title": "Command/exec/outputDeltaNotification", "type": "object" }, + { + "description": "Stream base64-encoded stdout/stderr chunks for a running `process/spawn` session.", + "properties": { + "method": { + "enum": [ + "process/outputDelta" + ], + "title": "Process/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ProcessOutputDeltaNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Process/outputDeltaNotification", + "type": "object" + }, + { + "description": "Final exit notification for a `process/spawn` session.", + "properties": { + "method": { + "enum": [ + "process/exited" + ], + "title": "Process/exitedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ProcessExitedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Process/exitedNotification", + "type": "object" + }, { "properties": { "method": { @@ -12115,13 +12333,6 @@ "title": "ServerRequestResolvedNotification", "type": "object" }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionMigration": { "properties": { "cwd": { @@ -12511,24 +12722,6 @@ ], "type": "object" }, - "SkillsListExtraRootsForCwd": { - "properties": { - "cwd": { - "type": "string" - }, - "extraUserRoots": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "cwd", - "extraUserRoots" - ], - "type": "object" - }, "SkillsListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -12542,17 +12735,6 @@ "forceReload": { "description": "When true, bypass the skills cache and re-scan skills from disk.", "type": "boolean" - }, - "perCwdExtraUserRoots": { - "default": null, - "description": "Optional per-cwd extra roots to scan as user-scoped skills.", - "items": { - "$ref": "#/definitions/SkillsListExtraRootsForCwd" - }, - "type": [ - "array", - "null" - ] } }, "title": "SkillsListParams", @@ -12836,6 +13018,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -12852,6 +13038,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -12873,6 +13070,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -13048,24 +13246,24 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { "type": "string" + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this forked thread." } }, "required": [ @@ -13124,13 +13322,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -14555,20 +14749,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { @@ -14631,13 +14814,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -14746,6 +14925,14 @@ ], "type": "string" }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadSourceKind": { "enum": [ "cli", @@ -14855,20 +15042,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sessionStartSource": { @@ -14880,6 +15056,17 @@ "type": "null" } ] + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this thread." } }, "title": "ThreadStartParams", @@ -14935,13 +15122,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { @@ -15310,12 +15493,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -15439,6 +15631,31 @@ "title": "TurnInterruptResponse", "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnPlanStep": { "properties": { "status": { @@ -15574,22 +15791,11 @@ "description": "Override the sandbox policy for this turn and subsequent turns." }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } - ], - "description": "Override the service tier for this turn and subsequent turns." + "description": "Override the service tier for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] }, "summary": { "anyOf": [ @@ -16022,6 +16228,27 @@ }, "type": "object" }, + "WindowsSandboxReadiness": { + "enum": [ + "ready", + "notConfigured", + "updateRequired" + ], + "type": "string" + }, + "WindowsSandboxReadinessResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "status": { + "$ref": "#/definitions/WindowsSandboxReadiness" + } + }, + "required": [ + "status" + ], + "title": "WindowsSandboxReadinessResponse", + "type": "object" + }, "WindowsSandboxSetupCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json index 6048b82242..af5c509249 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json @@ -39,6 +39,11 @@ "array", "null" ] + }, + "requestAttestation": { + "default": false, + "description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.", + "type": "boolean" } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index c348e7d955..87a826e5af 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -352,13 +352,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -658,13 +654,9 @@ ] }, "service_tier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "tools": { @@ -754,13 +746,6 @@ }, "type": "object" }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "ToolsV2": { "properties": { "view_image": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 545d8dc9b4..41bd337dec 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -62,6 +62,12 @@ }, "ConfigRequirements": { "properties": { + "allowManagedHooksOnly": { + "type": [ + "boolean", + "null" + ] + }, "allowedApprovalPolicies": { "items": { "$ref": "#/definitions/AskForApproval" @@ -121,6 +127,12 @@ "command": { "type": "string" }, + "commandWindows": { + "type": [ + "string", + "null" + ] + }, "statusMessage": { "type": [ "string", @@ -213,12 +225,24 @@ }, "type": "array" }, + "PostCompact": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "PostToolUse": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" }, "type": "array" }, + "PreCompact": { + "items": { + "$ref": "#/definitions/ConfiguredHookMatcherGroup" + }, + "type": "array" + }, "PreToolUse": { "items": { "$ref": "#/definitions/ConfiguredHookMatcherGroup" @@ -258,7 +282,9 @@ }, "required": [ "PermissionRequest", + "PostCompact", "PostToolUse", + "PreCompact", "PreToolUse", "SessionStart", "Stop", diff --git a/codex-rs/app-server-protocol/schema/json/v2/DeviceKeyCreateParams.json b/codex-rs/app-server-protocol/schema/json/v2/DeviceKeyCreateParams.json deleted file mode 100644 index fe2c0f0895..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v2/DeviceKeyCreateParams.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "DeviceKeyProtectionPolicy": { - "description": "Protection policy for creating or loading a controller-local device key.", - "enum": [ - "hardware_only", - "allow_os_protected_nonextractable" - ], - "type": "string" - } - }, - "description": "Create a controller-local device key with a random key id.", - "properties": { - "accountUserId": { - "type": "string" - }, - "clientId": { - "type": "string" - }, - "protectionPolicy": { - "anyOf": [ - { - "$ref": "#/definitions/DeviceKeyProtectionPolicy" - }, - { - "type": "null" - } - ], - "description": "Defaults to `hardware_only` when omitted." - } - }, - "required": [ - "accountUserId", - "clientId" - ], - "title": "DeviceKeyCreateParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/DeviceKeyCreateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/DeviceKeyCreateResponse.json deleted file mode 100644 index 12072588a9..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v2/DeviceKeyCreateResponse.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "DeviceKeyAlgorithm": { - "description": "Device-key algorithm reported at enrollment and signing boundaries.", - "enum": [ - "ecdsa_p256_sha256" - ], - "type": "string" - }, - "DeviceKeyProtectionClass": { - "description": "Platform protection class for a controller-local device key.", - "enum": [ - "hardware_secure_enclave", - "hardware_tpm", - "os_protected_nonextractable" - ], - "type": "string" - } - }, - "description": "Device-key metadata and public key returned by create/public APIs.", - "properties": { - "algorithm": { - "$ref": "#/definitions/DeviceKeyAlgorithm" - }, - "keyId": { - "type": "string" - }, - "protectionClass": { - "$ref": "#/definitions/DeviceKeyProtectionClass" - }, - "publicKeySpkiDerBase64": { - "description": "SubjectPublicKeyInfo DER encoded as base64.", - "type": "string" - } - }, - "required": [ - "algorithm", - "keyId", - "protectionClass", - "publicKeySpkiDerBase64" - ], - "title": "DeviceKeyCreateResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/DeviceKeyPublicResponse.json b/codex-rs/app-server-protocol/schema/json/v2/DeviceKeyPublicResponse.json deleted file mode 100644 index 39f98b7623..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v2/DeviceKeyPublicResponse.json +++ /dev/null @@ -1,45 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "DeviceKeyAlgorithm": { - "description": "Device-key algorithm reported at enrollment and signing boundaries.", - "enum": [ - "ecdsa_p256_sha256" - ], - "type": "string" - }, - "DeviceKeyProtectionClass": { - "description": "Platform protection class for a controller-local device key.", - "enum": [ - "hardware_secure_enclave", - "hardware_tpm", - "os_protected_nonextractable" - ], - "type": "string" - } - }, - "description": "Device-key public metadata returned by `device/key/public`.", - "properties": { - "algorithm": { - "$ref": "#/definitions/DeviceKeyAlgorithm" - }, - "keyId": { - "type": "string" - }, - "protectionClass": { - "$ref": "#/definitions/DeviceKeyProtectionClass" - }, - "publicKeySpkiDerBase64": { - "description": "SubjectPublicKeyInfo DER encoded as base64.", - "type": "string" - } - }, - "required": [ - "algorithm", - "keyId", - "protectionClass", - "publicKeySpkiDerBase64" - ], - "title": "DeviceKeyPublicResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/DeviceKeySignParams.json b/codex-rs/app-server-protocol/schema/json/v2/DeviceKeySignParams.json deleted file mode 100644 index 054765b297..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v2/DeviceKeySignParams.json +++ /dev/null @@ -1,165 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "DeviceKeySignPayload": { - "description": "Structured payloads accepted by `device/key/sign`.", - "oneOf": [ - { - "description": "Payload bound to one remote-control controller websocket `/client` connection challenge.", - "properties": { - "accountUserId": { - "type": "string" - }, - "audience": { - "$ref": "#/definitions/RemoteControlClientConnectionAudience" - }, - "clientId": { - "type": "string" - }, - "nonce": { - "type": "string" - }, - "scopes": { - "description": "Must contain exactly `remote_control_controller_websocket`.", - "items": { - "type": "string" - }, - "type": "array" - }, - "sessionId": { - "description": "Backend-issued websocket session id that this proof authorizes.", - "type": "string" - }, - "targetOrigin": { - "description": "Origin of the backend endpoint that issued the challenge and will verify this proof.", - "type": "string" - }, - "targetPath": { - "description": "Websocket route path that this proof authorizes.", - "type": "string" - }, - "tokenExpiresAt": { - "description": "Remote-control token expiration as Unix seconds.", - "format": "int64", - "type": "integer" - }, - "tokenSha256Base64url": { - "description": "SHA-256 of the controller-scoped remote-control token, encoded as unpadded base64url.", - "type": "string" - }, - "type": { - "enum": [ - "remoteControlClientConnection" - ], - "title": "RemoteControlClientConnectionDeviceKeySignPayloadType", - "type": "string" - } - }, - "required": [ - "accountUserId", - "audience", - "clientId", - "nonce", - "scopes", - "sessionId", - "targetOrigin", - "targetPath", - "tokenExpiresAt", - "tokenSha256Base64url", - "type" - ], - "title": "RemoteControlClientConnectionDeviceKeySignPayload", - "type": "object" - }, - { - "description": "Payload bound to a remote-control client `/client/enroll` ownership challenge.", - "properties": { - "accountUserId": { - "type": "string" - }, - "audience": { - "$ref": "#/definitions/RemoteControlClientEnrollmentAudience" - }, - "challengeExpiresAt": { - "description": "Enrollment challenge expiration as Unix seconds.", - "format": "int64", - "type": "integer" - }, - "challengeId": { - "description": "Backend-issued enrollment challenge id that this proof authorizes.", - "type": "string" - }, - "clientId": { - "type": "string" - }, - "deviceIdentitySha256Base64url": { - "description": "SHA-256 of the requested device identity operation, encoded as unpadded base64url.", - "type": "string" - }, - "nonce": { - "type": "string" - }, - "targetOrigin": { - "description": "Origin of the backend endpoint that issued the challenge and will verify this proof.", - "type": "string" - }, - "targetPath": { - "description": "HTTP route path that this proof authorizes.", - "type": "string" - }, - "type": { - "enum": [ - "remoteControlClientEnrollment" - ], - "title": "RemoteControlClientEnrollmentDeviceKeySignPayloadType", - "type": "string" - } - }, - "required": [ - "accountUserId", - "audience", - "challengeExpiresAt", - "challengeId", - "clientId", - "deviceIdentitySha256Base64url", - "nonce", - "targetOrigin", - "targetPath", - "type" - ], - "title": "RemoteControlClientEnrollmentDeviceKeySignPayload", - "type": "object" - } - ] - }, - "RemoteControlClientConnectionAudience": { - "description": "Audience for a remote-control client connection device-key proof.", - "enum": [ - "remote_control_client_websocket" - ], - "type": "string" - }, - "RemoteControlClientEnrollmentAudience": { - "description": "Audience for a remote-control client enrollment device-key proof.", - "enum": [ - "remote_control_client_enrollment" - ], - "type": "string" - } - }, - "description": "Sign an accepted structured payload with a controller-local device key.", - "properties": { - "keyId": { - "type": "string" - }, - "payload": { - "$ref": "#/definitions/DeviceKeySignPayload" - } - }, - "required": [ - "keyId", - "payload" - ], - "title": "DeviceKeySignParams", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/DeviceKeySignResponse.json b/codex-rs/app-server-protocol/schema/json/v2/DeviceKeySignResponse.json deleted file mode 100644 index 83fec90330..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v2/DeviceKeySignResponse.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "DeviceKeyAlgorithm": { - "description": "Device-key algorithm reported at enrollment and signing boundaries.", - "enum": [ - "ecdsa_p256_sha256" - ], - "type": "string" - } - }, - "description": "ASN.1 DER signature returned by `device/key/sign`.", - "properties": { - "algorithm": { - "$ref": "#/definitions/DeviceKeyAlgorithm" - }, - "signatureDerBase64": { - "description": "ECDSA signature DER encoded as base64.", - "type": "string" - }, - "signedPayloadBase64": { - "description": "Exact bytes signed by the device key, encoded as base64. Verifiers must verify this byte string directly and must not reserialize `payload`.", - "type": "string" - } - }, - "required": [ - "algorithm", - "signatureDerBase64", - "signedPayloadBase64" - ], - "title": "DeviceKeySignResponse", - "type": "object" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json index d55c059a73..f63e6a5cfe 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -10,6 +10,8 @@ "preToolUse", "permissionRequest", "postToolUse", + "preCompact", + "postCompact", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json index 03d2998ca5..f8eeecfe4d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -10,6 +10,8 @@ "preToolUse", "permissionRequest", "postToolUse", + "preCompact", + "postCompact", "sessionStart", "userPromptSubmit", "stop" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json index 5190b22711..f2a7c80cf0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -25,6 +25,8 @@ "preToolUse", "permissionRequest", "postToolUse", + "preCompact", + "postCompact", "sessionStart", "userPromptSubmit", "stop" @@ -47,6 +49,9 @@ "null" ] }, + "currentHash": { + "type": "string" + }, "displayOrder": { "format": "int64", "type": "integer" @@ -94,9 +99,13 @@ "format": "uint64", "minimum": 0.0, "type": "integer" + }, + "trustStatus": { + "$ref": "#/definitions/HookTrustStatus" } }, "required": [ + "currentHash", "displayOrder", "enabled", "eventName", @@ -105,7 +114,8 @@ "key", "source", "sourcePath", - "timeoutSec" + "timeoutSec", + "trustStatus" ], "type": "object" }, @@ -124,6 +134,15 @@ ], "type": "string" }, + "HookTrustStatus": { + "enum": [ + "managed", + "untrusted", + "trusted", + "modified" + ], + "type": "string" + }, "HooksListEntry": { "properties": { "cwd": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json index 0831483a32..6909415c2a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemCompletedNotification.json @@ -1370,6 +1370,11 @@ } }, "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle completed.", + "format": "int64", + "type": "integer" + }, "item": { "$ref": "#/definitions/ThreadItem" }, @@ -1381,6 +1386,7 @@ } }, "required": [ + "completedAtMs", "item", "threadId", "turnId" 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 98f44e50a2..991d4de050 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewCompletedNotification.json @@ -574,6 +574,11 @@ "action": { "$ref": "#/definitions/GuardianApprovalReviewAction" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review completed.", + "format": "int64", + "type": "integer" + }, "decisionSource": { "$ref": "#/definitions/AutoReviewDecisionSource" }, @@ -584,6 +589,11 @@ "description": "Stable identifier for this review.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, "targetItemId": { "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", "type": [ @@ -600,9 +610,11 @@ }, "required": [ "action", + "completedAtMs", "decisionSource", "review", "reviewId", + "startedAtMs", "threadId", "turnId" ], 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 16e47c2d72..75ffeb753a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemGuardianApprovalReviewStartedNotification.json @@ -574,6 +574,11 @@ "description": "Stable identifier for this review.", "type": "string" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, "targetItemId": { "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", "type": [ @@ -592,6 +597,7 @@ "action", "review", "reviewId", + "startedAtMs", "threadId", "turnId" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json index 16bfeece14..758ceba32d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ItemStartedNotification.json @@ -1373,6 +1373,11 @@ "item": { "$ref": "#/definitions/ThreadItem" }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -1382,6 +1387,7 @@ }, "required": [ "item", + "startedAtMs", "threadId", "turnId" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json index dc60c5b770..c0221805eb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelListResponse.json @@ -24,6 +24,7 @@ "properties": { "additionalSpeedTiers": { "default": [], + "description": "Deprecated: use `serviceTiers` instead.", "items": { "type": "string" }, @@ -70,6 +71,13 @@ "model": { "type": "string" }, + "serviceTiers": { + "default": [], + "items": { + "$ref": "#/definitions/ModelServiceTier" + }, + "type": "array" + }, "supportedReasoningEfforts": { "items": { "$ref": "#/definitions/ReasoningEffortOption" @@ -120,6 +128,25 @@ ], "type": "object" }, + "ModelServiceTier": { + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "description", + "id", + "name" + ], + "type": "object" + }, "ModelUpgradeInfo": { "properties": { "migrationMarkdown": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json index 27ea8c4df3..65b1b4e88d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json @@ -4,6 +4,14 @@ "AbsolutePathBuf": { "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" + }, + "PluginListMarketplaceKind": { + "enum": [ + "local", + "workspace-directory", + "shared-with-me" + ], + "type": "string" } }, "properties": { @@ -16,6 +24,16 @@ "array", "null" ] + }, + "marketplaceKinds": { + "description": "Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus the default remote catalog when enabled by feature flag.", + "items": { + "$ref": "#/definitions/PluginListMarketplaceKind" + }, + "type": [ + "array", + "null" + ] } }, "title": "PluginListParams", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json index dc383608f2..d756f61a68 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -232,6 +232,109 @@ ], "type": "object" }, + "PluginShareContext": { + "properties": { + "creatorAccountUserId": { + "type": [ + "string", + "null" + ] + }, + "creatorName": { + "type": [ + "string", + "null" + ] + }, + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, + "remotePluginId": { + "type": "string" + }, + "remoteVersion": { + "default": null, + "description": "Version of the remote shared plugin release when available.", + "type": [ + "string", + "null" + ] + }, + "sharePrincipals": { + "items": { + "$ref": "#/definitions/PluginSharePrincipal" + }, + "type": [ + "array", + "null" + ] + }, + "shareUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "remotePluginId" + ], + "type": "object" + }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginSharePrincipalRole" + } + }, + "required": [ + "name", + "principalId", + "principalType", + "role" + ], + "type": "object" + }, + "PluginSharePrincipalRole": { + "enum": [ + "reader", + "editor", + "owner" + ], + "type": "string" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, "PluginSource": { "oneOf": [ { @@ -347,9 +450,42 @@ } ] }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "localVersion": { + "default": null, + "description": "Version of the locally materialized plugin package when available.", + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, + "remotePluginId": { + "description": "Backend remote plugin identifier when available.", + "type": [ + "string", + "null" + ] + }, + "shareContext": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareContext" + }, + { + "type": "null" + } + ], + "description": "Remote sharing context associated with this plugin when available." + }, "source": { "$ref": "#/definitions/PluginSource" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index 2762807c7d..0c9f897dc9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -37,6 +37,19 @@ ], "type": "object" }, + "HookEventName": { + "enum": [ + "preToolUse", + "permissionRequest", + "postToolUse", + "preCompact", + "postCompact", + "sessionStart", + "userPromptSubmit", + "stop" + ], + "type": "string" + }, "PluginAuthPolicy": { "enum": [ "ON_INSTALL", @@ -75,6 +88,12 @@ "null" ] }, + "hooks": { + "items": { + "$ref": "#/definitions/PluginHookSummary" + }, + "type": "array" + }, "marketplaceName": { "type": "string" }, @@ -106,6 +125,7 @@ }, "required": [ "apps", + "hooks", "marketplaceName", "mcpServers", "skills", @@ -113,6 +133,21 @@ ], "type": "object" }, + "PluginHookSummary": { + "properties": { + "eventName": { + "$ref": "#/definitions/HookEventName" + }, + "key": { + "type": "string" + } + }, + "required": [ + "eventName", + "key" + ], + "type": "object" + }, "PluginInstallPolicy": { "enum": [ "NOT_AVAILABLE", @@ -251,6 +286,109 @@ ], "type": "object" }, + "PluginShareContext": { + "properties": { + "creatorAccountUserId": { + "type": [ + "string", + "null" + ] + }, + "creatorName": { + "type": [ + "string", + "null" + ] + }, + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, + "remotePluginId": { + "type": "string" + }, + "remoteVersion": { + "default": null, + "description": "Version of the remote shared plugin release when available.", + "type": [ + "string", + "null" + ] + }, + "sharePrincipals": { + "items": { + "$ref": "#/definitions/PluginSharePrincipal" + }, + "type": [ + "array", + "null" + ] + }, + "shareUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "remotePluginId" + ], + "type": "object" + }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginSharePrincipalRole" + } + }, + "required": [ + "name", + "principalId", + "principalType", + "role" + ], + "type": "object" + }, + "PluginSharePrincipalRole": { + "enum": [ + "reader", + "editor", + "owner" + ], + "type": "string" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, "PluginSource": { "oneOf": [ { @@ -366,9 +504,42 @@ } ] }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "localVersion": { + "default": null, + "description": "Version of the locally materialized plugin package when available.", + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, + "remotePluginId": { + "description": "Backend remote plugin identifier when available.", + "type": [ + "string", + "null" + ] + }, + "shareContext": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareContext" + }, + { + "type": "null" + } + ], + "description": "Remote sharing context associated with this plugin when available." + }, "source": { "$ref": "#/definitions/PluginSource" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json index adb5021be8..7cffdeab9a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json @@ -167,6 +167,70 @@ ], "type": "object" }, + "PluginShareContext": { + "properties": { + "creatorAccountUserId": { + "type": [ + "string", + "null" + ] + }, + "creatorName": { + "type": [ + "string", + "null" + ] + }, + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, + "remotePluginId": { + "type": "string" + }, + "remoteVersion": { + "default": null, + "description": "Version of the remote shared plugin release when available.", + "type": [ + "string", + "null" + ] + }, + "sharePrincipals": { + "items": { + "$ref": "#/definitions/PluginSharePrincipal" + }, + "type": [ + "array", + "null" + ] + }, + "shareUrl": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "remotePluginId" + ], + "type": "object" + }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, "PluginShareListItem": { "properties": { "localPluginPath": { @@ -181,17 +245,52 @@ }, "plugin": { "$ref": "#/definitions/PluginSummary" - }, - "shareUrl": { - "type": "string" } }, "required": [ - "plugin", - "shareUrl" + "plugin" ], "type": "object" }, + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginSharePrincipalRole" + } + }, + "required": [ + "name", + "principalId", + "principalType", + "role" + ], + "type": "object" + }, + "PluginSharePrincipalRole": { + "enum": [ + "reader", + "editor", + "owner" + ], + "type": "string" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, "PluginSource": { "oneOf": [ { @@ -307,9 +406,42 @@ } ] }, + "keywords": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "localVersion": { + "default": null, + "description": "Version of the locally materialized plugin package when available.", + "type": [ + "string", + "null" + ] + }, "name": { "type": "string" }, + "remotePluginId": { + "description": "Backend remote plugin identifier when available.", + "type": [ + "string", + "null" + ] + }, + "shareContext": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareContext" + }, + { + "type": "null" + } + ], + "description": "Remote sharing context associated with this plugin when available." + }, "source": { "$ref": "#/definitions/PluginSource" } diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json index ee1ae48730..7ff4ac18ef 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json @@ -4,9 +4,61 @@ "AbsolutePathBuf": { "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" + }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginShareTargetRole" + } + }, + "required": [ + "principalId", + "principalType", + "role" + ], + "type": "object" + }, + "PluginShareTargetRole": { + "enum": [ + "reader", + "editor" + ], + "type": "string" } }, "properties": { + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "pluginPath": { "$ref": "#/definitions/AbsolutePathBuf" }, @@ -15,6 +67,15 @@ "string", "null" ] + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": [ + "array", + "null" + ] } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json new file mode 100644 index 0000000000..38a7d8d29f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + }, + "PluginShareTarget": { + "properties": { + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginShareTargetRole" + } + }, + "required": [ + "principalId", + "principalType", + "role" + ], + "type": "object" + }, + "PluginShareTargetRole": { + "enum": [ + "reader", + "editor" + ], + "type": "string" + }, + "PluginShareUpdateDiscoverability": { + "enum": [ + "UNLISTED", + "PRIVATE" + ], + "type": "string" + } + }, + "properties": { + "discoverability": { + "$ref": "#/definitions/PluginShareUpdateDiscoverability" + }, + "remotePluginId": { + "type": "string" + }, + "shareTargets": { + "items": { + "$ref": "#/definitions/PluginShareTarget" + }, + "type": "array" + } + }, + "required": [ + "discoverability", + "remotePluginId", + "shareTargets" + ], + "title": "PluginShareUpdateTargetsParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json new file mode 100644 index 0000000000..4923be498a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json @@ -0,0 +1,69 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, + "PluginSharePrincipal": { + "properties": { + "name": { + "type": "string" + }, + "principalId": { + "type": "string" + }, + "principalType": { + "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginSharePrincipalRole" + } + }, + "required": [ + "name", + "principalId", + "principalType", + "role" + ], + "type": "object" + }, + "PluginSharePrincipalRole": { + "enum": [ + "reader", + "editor", + "owner" + ], + "type": "string" + }, + "PluginSharePrincipalType": { + "enum": [ + "user", + "group", + "workspace" + ], + "type": "string" + } + }, + "properties": { + "discoverability": { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + "principals": { + "items": { + "$ref": "#/definitions/PluginSharePrincipal" + }, + "type": "array" + } + }, + "required": [ + "discoverability", + "principals" + ], + "title": "PluginShareUpdateTargetsResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ProcessExitedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ProcessExitedNotification.json new file mode 100644 index 0000000000..3a0a81d316 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ProcessExitedNotification.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Final process exit notification for `process/spawn`.", + "properties": { + "exitCode": { + "description": "Process exit code.", + "format": "int32", + "type": "integer" + }, + "processHandle": { + "description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.", + "type": "string" + }, + "stderr": { + "description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `process/outputDelta`.", + "type": "string" + }, + "stderrCapReached": { + "description": "Whether stderr reached `outputBytesCap`.\n\nIn streaming mode, stderr is empty and cap state is also reported on the final stderr `process/outputDelta` notification.", + "type": "boolean" + }, + "stdout": { + "description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `process/outputDelta`.", + "type": "string" + }, + "stdoutCapReached": { + "description": "Whether stdout reached `outputBytesCap`.\n\nIn streaming mode, stdout is empty and cap state is also reported on the final stdout `process/outputDelta` notification.", + "type": "boolean" + } + }, + "required": [ + "exitCode", + "processHandle", + "stderr", + "stderrCapReached", + "stdout", + "stdoutCapReached" + ], + "title": "ProcessExitedNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ProcessOutputDeltaNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ProcessOutputDeltaNotification.json new file mode 100644 index 0000000000..1800833f2e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ProcessOutputDeltaNotification.json @@ -0,0 +1,55 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ProcessOutputStream": { + "description": "Stream label for `process/outputDelta` notifications.", + "oneOf": [ + { + "description": "stdout stream. PTY mode multiplexes terminal output here.", + "enum": [ + "stdout" + ], + "type": "string" + }, + { + "description": "stderr stream.", + "enum": [ + "stderr" + ], + "type": "string" + } + ] + } + }, + "description": "Base64-encoded output chunk emitted for a streaming `process/spawn` request.", + "properties": { + "capReached": { + "description": "True on the final streamed chunk for this stream when output was truncated by `outputBytesCap`.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Base64-encoded output bytes.", + "type": "string" + }, + "processHandle": { + "description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.", + "type": "string" + }, + "stream": { + "allOf": [ + { + "$ref": "#/definitions/ProcessOutputStream" + } + ], + "description": "Output stream this chunk belongs to." + } + }, + "required": [ + "capReached", + "deltaBase64", + "processHandle", + "stream" + ], + "title": "ProcessOutputDeltaNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index 92117cf36d..6973d15baa 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -732,6 +732,28 @@ "title": "CompactionResponseItem", "type": "object" }, + { + "properties": { + "encrypted_content": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "context_compaction" + ], + "title": "ContextCompactionResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ContextCompactionResponseItem", + "type": "object" + }, { "properties": { "type": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json index 8286815ff4..85be3316d7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json @@ -11,7 +11,7 @@ "type": "string" } }, - "description": "Current remote-control connection status and environment id exposed to clients.", + "description": "Current remote-control connection status and remote identity exposed to clients.", "properties": { "environmentId": { "type": [ @@ -19,11 +19,15 @@ "null" ] }, + "installationId": { + "type": "string" + }, "status": { "$ref": "#/definitions/RemoteControlConnectionStatus" } }, "required": [ + "installationId", "status" ], "title": "RemoteControlStatusChangedNotification", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index 16abcd7806..9afd1ae514 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -1324,12 +1324,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -1377,6 +1386,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", diff --git a/codex-rs/app-server-protocol/schema/json/v2/SkillsListParams.json b/codex-rs/app-server-protocol/schema/json/v2/SkillsListParams.json index 77d12e9175..a9a8a9ef8d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/SkillsListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/SkillsListParams.json @@ -1,25 +1,5 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "definitions": { - "SkillsListExtraRootsForCwd": { - "properties": { - "cwd": { - "type": "string" - }, - "extraUserRoots": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "cwd", - "extraUserRoots" - ], - "type": "object" - } - }, "properties": { "cwds": { "description": "When empty, defaults to the current session working directory.", @@ -31,17 +11,6 @@ "forceReload": { "description": "When true, bypass the skills cache and re-scan skills from disk.", "type": "boolean" - }, - "perCwdExtraUserRoots": { - "default": null, - "description": "Optional per-cwd extra roots to scan as user-scoped skills.", - "items": { - "$ref": "#/definitions/SkillsListExtraRootsForCwd" - }, - "type": [ - "array", - "null" - ] } }, "title": "SkillsListParams", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json index 970e2fe9ca..29d67403cd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -131,10 +131,11 @@ ], "type": "string" }, - "ServiceTier": { + "ThreadSource": { "enum": [ - "fast", - "flex" + "user", + "subagent", + "memory_consolidation" ], "type": "string" } @@ -214,24 +215,24 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { "type": "string" + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this forked thread." } }, "required": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 653c5f2387..6e74ab4ac8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1177,13 +1177,6 @@ } ] }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionSource": { "oneOf": [ { @@ -1403,6 +1396,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -1419,6 +1416,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -1440,6 +1448,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -2117,6 +2126,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { @@ -2225,12 +2242,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -2278,6 +2304,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", @@ -2557,13 +2608,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index 2f5cbb9500..f78fbaf27e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -869,6 +873,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -890,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -1567,6 +1583,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { @@ -1675,12 +1699,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -1728,6 +1761,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index b9ae59708a..4268ad203a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -869,6 +873,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -890,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -1567,6 +1583,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { @@ -1675,12 +1699,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -1728,6 +1761,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index cda474c294..fb0d80a047 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -869,6 +873,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -890,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -1567,6 +1583,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { @@ -1675,12 +1699,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -1728,6 +1761,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index cfe6509195..5f07fe0149 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -862,6 +862,28 @@ "title": "CompactionResponseItem", "type": "object" }, + { + "properties": { + "encrypted_content": { + "type": [ + "string", + "null" + ] + }, + "type": { + "enum": [ + "context_compaction" + ], + "title": "ContextCompactionResponseItemType", + "type": "string" + } + }, + "required": [ + "type" + ], + "title": "ContextCompactionResponseItem", + "type": "object" + }, { "properties": { "type": { @@ -988,13 +1010,6 @@ "danger-full-access" ], "type": "string" - }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" } }, "description": "There are three ways to resume a thread: 1. By thread_id: load the thread from disk by thread_id and resume it. 2. By history: instantiate the thread from memory and resume it. 3. By path: load the thread from disk by path and resume it.\n\nThe precedence is: history > path > thread_id. If using history or path, the thread_id param will be ignored.\n\nPrefer using thread_id whenever possible.", @@ -1079,20 +1094,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "threadId": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 27cf47f2fc..727b7a3fb2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1177,13 +1177,6 @@ } ] }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionSource": { "oneOf": [ { @@ -1403,6 +1396,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -1419,6 +1416,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -1440,6 +1448,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -2117,6 +2126,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { @@ -2225,12 +2242,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -2278,6 +2304,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", @@ -2557,13 +2608,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index e5339f4e99..204828c732 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -869,6 +873,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -890,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -1567,6 +1583,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { @@ -1675,12 +1699,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -1728,6 +1761,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index d5f0e9bfcc..9a60049a61 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -165,10 +165,11 @@ ], "type": "string" }, - "ServiceTier": { + "ThreadSource": { "enum": [ - "fast", - "flex" + "user", + "subagent", + "memory_consolidation" ], "type": "string" }, @@ -287,20 +288,9 @@ ] }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "sessionStartSource": { @@ -312,6 +302,17 @@ "type": "null" } ] + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional client-supplied analytics source classification for this thread." } }, "title": "ThreadStartParams", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 7d93606aa4..bf03f0fb55 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1177,13 +1177,6 @@ } ] }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionSource": { "oneOf": [ { @@ -1403,6 +1396,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -1419,6 +1416,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -1440,6 +1448,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -2117,6 +2126,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { @@ -2225,12 +2242,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -2278,6 +2304,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", @@ -2557,13 +2608,9 @@ "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." }, "serviceTier": { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } + "type": [ + "string", + "null" ] }, "thread": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 774686e46a..759b5990be 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -869,6 +873,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -890,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -1567,6 +1583,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { @@ -1675,12 +1699,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -1728,6 +1761,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 64179af7e1..f64400129a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -853,6 +853,10 @@ "description": "Usually the first user message in the thread, if available.", "type": "string" }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, "source": { "allOf": [ { @@ -869,6 +873,17 @@ ], "description": "Current runtime status for the thread." }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, "turns": { "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", "items": { @@ -890,6 +905,7 @@ "id", "modelProvider", "preview", + "sessionId", "source", "status", "turns", @@ -1567,6 +1583,14 @@ } ] }, + "ThreadSource": { + "enum": [ + "user", + "subagent", + "memory_consolidation" + ], + "type": "string" + }, "ThreadStatus": { "oneOf": [ { @@ -1675,12 +1699,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -1728,6 +1761,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 0739fa31bc..e5e2558e9c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -1324,12 +1324,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -1377,6 +1386,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index da1320a796..1ef33d4301 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -312,13 +312,6 @@ } ] }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "Settings": { "description": "Settings for a collaboration mode.", "properties": { @@ -586,22 +579,11 @@ "description": "Override the sandbox policy for this turn and subsequent turns." }, "serviceTier": { - "anyOf": [ - { - "anyOf": [ - { - "$ref": "#/definitions/ServiceTier" - }, - { - "type": "null" - } - ] - }, - { - "type": "null" - } - ], - "description": "Override the service tier for this turn and subsequent turns." + "description": "Override the service tier for this turn and subsequent turns.", + "type": [ + "string", + "null" + ] }, "summary": { "anyOf": [ diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index bc5917ef15..a2eff7fdd8 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -1324,12 +1324,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -1377,6 +1386,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 22ad85d906..0952db2aca 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -1324,12 +1324,21 @@ "type": "string" }, "items": { - "description": "Only populated on a `thread/resume` or `thread/fork` response. For all other responses and notifications returning a Turn, the items field will be an empty list.", + "description": "Thread items currently included in this turn payload.", "items": { "$ref": "#/definitions/ThreadItem" }, "type": "array" }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, "startedAt": { "description": "Unix timestamp (in seconds) when the turn started.", "format": "int64", @@ -1377,6 +1386,31 @@ ], "type": "object" }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": [ + "notLoaded" + ], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": [ + "summary" + ], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": [ + "full" + ], + "type": "string" + } + ] + }, "TurnStatus": { "enum": [ "completed", diff --git a/codex-rs/app-server-protocol/schema/json/v2/WindowsSandboxReadinessResponse.json b/codex-rs/app-server-protocol/schema/json/v2/WindowsSandboxReadinessResponse.json new file mode 100644 index 0000000000..de5ee264cb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/WindowsSandboxReadinessResponse.json @@ -0,0 +1,23 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "WindowsSandboxReadiness": { + "enum": [ + "ready", + "notConfigured", + "updateRequired" + ], + "type": "string" + } + }, + "properties": { + "status": { + "$ref": "#/definitions/WindowsSandboxReadiness" + } + }, + "required": [ + "status" + ], + "title": "WindowsSandboxReadinessResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 989dbb6551..a12185b501 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -16,9 +16,6 @@ import type { CommandExecWriteParams } from "./v2/CommandExecWriteParams"; import type { ConfigBatchWriteParams } from "./v2/ConfigBatchWriteParams"; import type { ConfigReadParams } from "./v2/ConfigReadParams"; import type { ConfigValueWriteParams } from "./v2/ConfigValueWriteParams"; -import type { DeviceKeyCreateParams } from "./v2/DeviceKeyCreateParams"; -import type { DeviceKeyPublicParams } from "./v2/DeviceKeyPublicParams"; -import type { DeviceKeySignParams } from "./v2/DeviceKeySignParams"; import type { ExperimentalFeatureEnablementSetParams } from "./v2/ExperimentalFeatureEnablementSetParams"; import type { ExperimentalFeatureListParams } from "./v2/ExperimentalFeatureListParams"; import type { ExternalAgentConfigDetectParams } from "./v2/ExternalAgentConfigDetectParams"; @@ -51,6 +48,7 @@ import type { PluginReadParams } from "./v2/PluginReadParams"; import type { PluginShareDeleteParams } from "./v2/PluginShareDeleteParams"; import type { PluginShareListParams } from "./v2/PluginShareListParams"; import type { PluginShareSaveParams } from "./v2/PluginShareSaveParams"; +import type { PluginShareUpdateTargetsParams } from "./v2/PluginShareUpdateTargetsParams"; import type { PluginSkillReadParams } from "./v2/PluginSkillReadParams"; import type { PluginUninstallParams } from "./v2/PluginUninstallParams"; import type { ReviewStartParams } from "./v2/ReviewStartParams"; @@ -81,4 +79,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "device/key/create", id: RequestId, params: DeviceKeyCreateParams, } | { "method": "device/key/public", id: RequestId, params: DeviceKeyPublicParams, } | { "method": "device/key/sign", id: RequestId, params: DeviceKeySignParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts index 5d42cc4852..c5043e3b64 100644 --- a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts +++ b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts @@ -10,6 +10,10 @@ export type InitializeCapabilities = { * Opt into receiving experimental API methods and fields. */ experimentalApi: boolean, +/** + * Opt into `attestation/generate` requests for upstream `x-oai-attestation`. + */ +requestAttestation: boolean, /** * Exact notification method names that should be suppressed for this * connection (for example `thread/started`). diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index 382c89db7d..6fa9beee25 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -14,4 +14,4 @@ export type ResponseItem = { "type": "message", role: string, content: Array, }; +export type AttestationGenerateParams = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeyPublicParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AttestationGenerateResponse.ts similarity index 58% rename from codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeyPublicParams.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/AttestationGenerateResponse.ts index 5a5b77899d..6821c898ec 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeyPublicParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AttestationGenerateResponse.ts @@ -2,7 +2,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +export type AttestationGenerateResponse = { /** - * Fetch a controller-local device key public key by id. + * Opaque client attestation token. */ -export type DeviceKeyPublicParams = { keyId: string, }; +token: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts index ca2d0b0aa0..0e9100836a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CommandExecutionRequestApprovalParams.ts @@ -8,6 +8,9 @@ import type { NetworkApprovalContext } from "./NetworkApprovalContext"; import type { NetworkPolicyAmendment } from "./NetworkPolicyAmendment"; export type CommandExecutionRequestApprovalParams = {threadId: string, turnId: string, itemId: string, /** + * Unix timestamp (in milliseconds) when this approval request started. + */ +startedAtMs: number, /** * Unique identifier for this specific approval callback. * * For regular shell/unified_exec approvals, this is null. diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts index 508fe84e92..cc7e340ea3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Config.ts @@ -4,7 +4,6 @@ import type { ForcedLoginMethod } from "../ForcedLoginMethod"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; -import type { ServiceTier } from "../ServiceTier"; import type { Verbosity } from "../Verbosity"; import type { WebSearchMode } from "../WebSearchMode"; import type { JsonValue } from "../serde_json/JsonValue"; @@ -20,4 +19,4 @@ export type Config = {model: string | null, review_model: string | null, model_c * [UNSTABLE] Optional default for where approval requests are routed for * review. */ -approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: ServiceTier | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +approvals_reviewer: ApprovalsReviewer | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, service_tier: string | null, analytics: AnalyticsConfig | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts index 47a99453fe..1b0625e5e1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigRequirements.ts @@ -6,4 +6,4 @@ import type { AskForApproval } from "./AskForApproval"; import type { ResidencyRequirement } from "./ResidencyRequirement"; import type { SandboxMode } from "./SandboxMode"; -export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWebSearchModes: Array | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; +export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts index a81ce61f6e..42b05cf8cc 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfiguredHookHandler.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ConfiguredHookHandler = { "type": "command", command: string, timeoutSec: bigint | null, async: boolean, statusMessage: string | null, } | { "type": "prompt", } | { "type": "agent", }; +export type ConfiguredHookHandler = { "type": "command", command: string, commandWindows: string | null, timeoutSec: bigint | null, async: boolean, statusMessage: string | null, } | { "type": "prompt", } | { "type": "agent", }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeyCreateParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeyCreateParams.ts deleted file mode 100644 index 7ffd9b5fa3..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeyCreateParams.ts +++ /dev/null @@ -1,13 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DeviceKeyProtectionPolicy } from "./DeviceKeyProtectionPolicy"; - -/** - * Create a controller-local device key with a random key id. - */ -export type DeviceKeyCreateParams = { -/** - * Defaults to `hardware_only` when omitted. - */ -protectionPolicy?: DeviceKeyProtectionPolicy | null, accountUserId: string, clientId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeyCreateResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeyCreateResponse.ts deleted file mode 100644 index 6ace37934a..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeyCreateResponse.ts +++ /dev/null @@ -1,14 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DeviceKeyAlgorithm } from "./DeviceKeyAlgorithm"; -import type { DeviceKeyProtectionClass } from "./DeviceKeyProtectionClass"; - -/** - * Device-key metadata and public key returned by create/public APIs. - */ -export type DeviceKeyCreateResponse = { keyId: string, -/** - * SubjectPublicKeyInfo DER encoded as base64. - */ -publicKeySpkiDerBase64: string, algorithm: DeviceKeyAlgorithm, protectionClass: DeviceKeyProtectionClass, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeyProtectionClass.ts b/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeyProtectionClass.ts deleted file mode 100644 index ba7ff311ad..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeyProtectionClass.ts +++ /dev/null @@ -1,8 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Platform protection class for a controller-local device key. - */ -export type DeviceKeyProtectionClass = "hardware_secure_enclave" | "hardware_tpm" | "os_protected_nonextractable"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeyProtectionPolicy.ts b/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeyProtectionPolicy.ts deleted file mode 100644 index 66fceafb51..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeyProtectionPolicy.ts +++ /dev/null @@ -1,8 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Protection policy for creating or loading a controller-local device key. - */ -export type DeviceKeyProtectionPolicy = "hardware_only" | "allow_os_protected_nonextractable"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeyPublicResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeyPublicResponse.ts deleted file mode 100644 index 9967c0936e..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeyPublicResponse.ts +++ /dev/null @@ -1,14 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DeviceKeyAlgorithm } from "./DeviceKeyAlgorithm"; -import type { DeviceKeyProtectionClass } from "./DeviceKeyProtectionClass"; - -/** - * Device-key public metadata returned by `device/key/public`. - */ -export type DeviceKeyPublicResponse = { keyId: string, -/** - * SubjectPublicKeyInfo DER encoded as base64. - */ -publicKeySpkiDerBase64: string, algorithm: DeviceKeyAlgorithm, protectionClass: DeviceKeyProtectionClass, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeySignParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeySignParams.ts deleted file mode 100644 index 0886e45d93..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeySignParams.ts +++ /dev/null @@ -1,9 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DeviceKeySignPayload } from "./DeviceKeySignPayload"; - -/** - * Sign an accepted structured payload with a controller-local device key. - */ -export type DeviceKeySignParams = { keyId: string, payload: DeviceKeySignPayload, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeySignPayload.ts b/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeySignPayload.ts deleted file mode 100644 index 8596445490..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeySignPayload.ts +++ /dev/null @@ -1,54 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { RemoteControlClientConnectionAudience } from "./RemoteControlClientConnectionAudience"; -import type { RemoteControlClientEnrollmentAudience } from "./RemoteControlClientEnrollmentAudience"; - -/** - * Structured payloads accepted by `device/key/sign`. - */ -export type DeviceKeySignPayload = { "type": "remoteControlClientConnection", nonce: string, audience: RemoteControlClientConnectionAudience, -/** - * Backend-issued websocket session id that this proof authorizes. - */ -sessionId: string, -/** - * Origin of the backend endpoint that issued the challenge and will verify this proof. - */ -targetOrigin: string, -/** - * Websocket route path that this proof authorizes. - */ -targetPath: string, accountUserId: string, clientId: string, -/** - * Remote-control token expiration as Unix seconds. - */ -tokenExpiresAt: number, -/** - * SHA-256 of the controller-scoped remote-control token, encoded as unpadded base64url. - */ -tokenSha256Base64url: string, -/** - * Must contain exactly `remote_control_controller_websocket`. - */ -scopes: Array, } | { "type": "remoteControlClientEnrollment", nonce: string, audience: RemoteControlClientEnrollmentAudience, -/** - * Backend-issued enrollment challenge id that this proof authorizes. - */ -challengeId: string, -/** - * Origin of the backend endpoint that issued the challenge and will verify this proof. - */ -targetOrigin: string, -/** - * HTTP route path that this proof authorizes. - */ -targetPath: string, accountUserId: string, clientId: string, -/** - * SHA-256 of the requested device identity operation, encoded as unpadded base64url. - */ -deviceIdentitySha256Base64url: string, -/** - * Enrollment challenge expiration as Unix seconds. - */ -challengeExpiresAt: number, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeySignResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeySignResponse.ts deleted file mode 100644 index cf77fae27f..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeySignResponse.ts +++ /dev/null @@ -1,18 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DeviceKeyAlgorithm } from "./DeviceKeyAlgorithm"; - -/** - * ASN.1 DER signature returned by `device/key/sign`. - */ -export type DeviceKeySignResponse = { -/** - * ECDSA signature DER encoded as base64. - */ -signatureDerBase64: string, -/** - * Exact bytes signed by the device key, encoded as base64. Verifiers must verify this byte - * string directly and must not reserialize `payload`. - */ -signedPayloadBase64: string, algorithm: DeviceKeyAlgorithm, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FileChangeRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FileChangeRequestApprovalParams.ts index c514ed6219..2db7be9ec4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/FileChangeRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FileChangeRequestApprovalParams.ts @@ -3,6 +3,10 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type FileChangeRequestApprovalParams = { threadId: string, turnId: string, itemId: string, +/** + * Unix timestamp (in milliseconds) when this approval request started. + */ +startedAtMs: number, /** * Optional explanatory reason (e.g. request for extra write access). */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts index 28657d2282..91c2def709 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookEventName.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "sessionStart" | "userPromptSubmit" | "stop"; +export type HookEventName = "preToolUse" | "permissionRequest" | "postToolUse" | "preCompact" | "postCompact" | "sessionStart" | "userPromptSubmit" | "stop"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts index 8ccd2b1825..94e3c30c92 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookMetadata.ts @@ -5,5 +5,6 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { HookEventName } from "./HookEventName"; import type { HookHandlerType } from "./HookHandlerType"; import type { HookSource } from "./HookSource"; +import type { HookTrustStatus } from "./HookTrustStatus"; -export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, isManaged: boolean, }; +export type HookMetadata = { key: string, eventName: HookEventName, handlerType: HookHandlerType, matcher: string | null, command: string | null, timeoutSec: bigint, statusMessage: string | null, sourcePath: AbsolutePathBuf, source: HookSource, pluginId: string | null, displayOrder: bigint, enabled: boolean, isManaged: boolean, currentHash: string, trustStatus: HookTrustStatus, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookTrustStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookTrustStatus.ts new file mode 100644 index 0000000000..692fdc4c11 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookTrustStatus.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 HookTrustStatus = "managed" | "untrusted" | "trusted" | "modified"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ItemCompletedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ItemCompletedNotification.ts index 96122204b4..25ced4a075 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ItemCompletedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ItemCompletedNotification.ts @@ -3,4 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ThreadItem } from "./ThreadItem"; -export type ItemCompletedNotification = { item: ThreadItem, threadId: string, turnId: string, }; +export type ItemCompletedNotification = { item: ThreadItem, threadId: string, turnId: string, +/** + * Unix timestamp (in milliseconds) when this item lifecycle completed. + */ +completedAtMs: number, }; 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 5b162cf4b9..32d12be608 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewCompletedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewCompletedNotification.ts @@ -10,6 +10,14 @@ import type { GuardianApprovalReviewAction } from "./GuardianApprovalReviewActio * shape is expected to change soon. */ export type ItemGuardianApprovalReviewCompletedNotification = { threadId: string, turnId: string, +/** + * Unix timestamp (in milliseconds) when this review started. + */ +startedAtMs: number, +/** + * Unix timestamp (in milliseconds) when this review completed. + */ +completedAtMs: number, /** * Stable identifier for this review. */ 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 81ba2cdebf..92d34fdebc 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewStartedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ItemGuardianApprovalReviewStartedNotification.ts @@ -9,6 +9,10 @@ import type { GuardianApprovalReviewAction } from "./GuardianApprovalReviewActio * shape is expected to change soon. */ export type ItemGuardianApprovalReviewStartedNotification = { threadId: string, turnId: string, +/** + * Unix timestamp (in milliseconds) when this review started. + */ +startedAtMs: number, /** * Stable identifier for this review. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ItemStartedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ItemStartedNotification.ts index 5cf1e7b918..9ec8af09e9 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ItemStartedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ItemStartedNotification.ts @@ -3,4 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ThreadItem } from "./ThreadItem"; -export type ItemStartedNotification = { item: ThreadItem, threadId: string, turnId: string, }; +export type ItemStartedNotification = { item: ThreadItem, threadId: string, turnId: string, +/** + * Unix timestamp (in milliseconds) when this item lifecycle started. + */ +startedAtMs: number, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts index 3386d16ec3..cde0e4a503 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ManagedHooksRequirements.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup"; -export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, SessionStart: Array, UserPromptSubmit: Array, Stop: Array, }; +export type ManagedHooksRequirements = { managedDir: string | null, windowsManagedDir: string | null, PreToolUse: Array, PermissionRequest: Array, PostToolUse: Array, PreCompact: Array, PostCompact: Array, SessionStart: Array, UserPromptSubmit: Array, Stop: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts index f4cf5a9462..2354ffbf9e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Model.ts @@ -4,7 +4,12 @@ import type { InputModality } from "../InputModality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ModelAvailabilityNux } from "./ModelAvailabilityNux"; +import type { ModelServiceTier } from "./ModelServiceTier"; import type { ModelUpgradeInfo } from "./ModelUpgradeInfo"; import type { ReasoningEffortOption } from "./ReasoningEffortOption"; -export type Model = { id: string, model: string, upgrade: string | null, upgradeInfo: ModelUpgradeInfo | null, availabilityNux: ModelAvailabilityNux | null, displayName: string, description: string, hidden: boolean, supportedReasoningEfforts: Array, defaultReasoningEffort: ReasoningEffort, inputModalities: Array, supportsPersonality: boolean, additionalSpeedTiers: Array, isDefault: boolean, }; +export type Model = { id: string, model: string, upgrade: string | null, upgradeInfo: ModelUpgradeInfo | null, availabilityNux: ModelAvailabilityNux | null, displayName: string, description: string, hidden: boolean, supportedReasoningEfforts: Array, defaultReasoningEffort: ReasoningEffort, inputModalities: Array, supportsPersonality: boolean, +/** + * Deprecated: use `serviceTiers` instead. + */ +additionalSpeedTiers: Array, serviceTiers: Array, isDefault: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ModelServiceTier.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ModelServiceTier.ts new file mode 100644 index 0000000000..09693d0788 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ModelServiceTier.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 ModelServiceTier = { id: string, name: string, description: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts index 308670a809..509f60923b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionsRequestApprovalParams.ts @@ -4,4 +4,8 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { RequestPermissionProfile } from "./RequestPermissionProfile"; -export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, cwd: AbsolutePathBuf, reason: string | null, permissions: RequestPermissionProfile, }; +export type PermissionsRequestApprovalParams = { threadId: string, turnId: string, itemId: string, +/** + * Unix timestamp (in milliseconds) when this approval request started. + */ +startedAtMs: number, cwd: AbsolutePathBuf, reason: string | null, permissions: RequestPermissionProfile, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts index eb0f38caa6..64836c87f7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginDetail.ts @@ -3,7 +3,8 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { AppSummary } from "./AppSummary"; +import type { PluginHookSummary } from "./PluginHookSummary"; import type { PluginSummary } from "./PluginSummary"; import type { SkillSummary } from "./SkillSummary"; -export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array, apps: Array, mcpServers: Array, }; +export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array, hooks: Array, apps: Array, mcpServers: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginHookSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginHookSummary.ts new file mode 100644 index 0000000000..48046bbd7a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginHookSummary.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { HookEventName } from "./HookEventName"; + +export type PluginHookSummary = { key: string, eventName: HookEventName, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts new file mode 100644 index 0000000000..6ff6161f34 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.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 PluginListMarketplaceKind = "local" | "workspace-directory" | "shared-with-me"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts index dcf23796db..6dd86b8a41 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListParams.ts @@ -2,10 +2,16 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { PluginListMarketplaceKind } from "./PluginListMarketplaceKind"; export type PluginListParams = { /** * Optional working directories used to discover repo marketplaces. When omitted, * only home-scoped marketplaces and the official curated marketplace are considered. */ -cwds?: Array | null, }; +cwds?: Array | null, +/** + * Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus + * the default remote catalog when enabled by feature flag. + */ +marketplaceKinds?: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts new file mode 100644 index 0000000000..99b8f46601 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts @@ -0,0 +1,11 @@ +// 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 { PluginShareDiscoverability } from "./PluginShareDiscoverability"; +import type { PluginSharePrincipal } from "./PluginSharePrincipal"; + +export type PluginShareContext = { remotePluginId: string, +/** + * Version of the remote shared plugin release when available. + */ +remoteVersion: string | null, discoverability: PluginShareDiscoverability | null, shareUrl: string | null, creatorAccountUserId: string | null, creatorName: string | null, sharePrincipals: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareDiscoverability.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareDiscoverability.ts new file mode 100644 index 0000000000..8c2242163b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareDiscoverability.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 PluginShareDiscoverability = "LISTED" | "UNLISTED" | "PRIVATE"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListItem.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListItem.ts index b63738aacd..aa5aa4ee4b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareListItem.ts @@ -4,4 +4,4 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { PluginSummary } from "./PluginSummary"; -export type PluginShareListItem = { plugin: PluginSummary, shareUrl: string, localPluginPath: AbsolutePathBuf | null, }; +export type PluginShareListItem = { plugin: PluginSummary, localPluginPath: AbsolutePathBuf | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipal.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipal.ts new file mode 100644 index 0000000000..dd0dff2009 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipal.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 { PluginSharePrincipalRole } from "./PluginSharePrincipalRole"; +import type { PluginSharePrincipalType } from "./PluginSharePrincipalType"; + +export type PluginSharePrincipal = { principalType: PluginSharePrincipalType, principalId: string, role: PluginSharePrincipalRole, name: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalRole.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalRole.ts new file mode 100644 index 0000000000..0a022a0bcd --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalRole.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 PluginSharePrincipalRole = "reader" | "editor" | "owner"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalType.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalType.ts new file mode 100644 index 0000000000..e54c129cbf --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalType.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 PluginSharePrincipalType = "user" | "group" | "workspace"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.ts index d2011984e3..c8df0d6c1c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareSaveParams.ts @@ -2,5 +2,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { PluginShareDiscoverability } from "./PluginShareDiscoverability"; +import type { PluginShareTarget } from "./PluginShareTarget"; -export type PluginShareSaveParams = { pluginPath: AbsolutePathBuf, remotePluginId?: string | null, }; +export type PluginShareSaveParams = { pluginPath: AbsolutePathBuf, remotePluginId?: string | null, discoverability?: PluginShareDiscoverability | null, shareTargets?: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.ts new file mode 100644 index 0000000000..66d22ef4a6 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.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 { PluginSharePrincipalType } from "./PluginSharePrincipalType"; +import type { PluginShareTargetRole } from "./PluginShareTargetRole"; + +export type PluginShareTarget = { principalType: PluginSharePrincipalType, principalId: string, role: PluginShareTargetRole, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTargetRole.ts similarity index 72% rename from codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTargetRole.ts index ce11286dbd..95eee17be0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServiceTier.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTargetRole.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ServiceTier = "fast" | "flex"; +export type PluginShareTargetRole = "reader" | "editor"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateDiscoverability.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateDiscoverability.ts new file mode 100644 index 0000000000..fd601987af --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateDiscoverability.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 PluginShareUpdateDiscoverability = "UNLISTED" | "PRIVATE"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsParams.ts new file mode 100644 index 0000000000..eecd4be82b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsParams.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 { PluginShareTarget } from "./PluginShareTarget"; +import type { PluginShareUpdateDiscoverability } from "./PluginShareUpdateDiscoverability"; + +export type PluginShareUpdateTargetsParams = { remotePluginId: string, discoverability: PluginShareUpdateDiscoverability, shareTargets: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsResponse.ts new file mode 100644 index 0000000000..0ce722460f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsResponse.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 { PluginShareDiscoverability } from "./PluginShareDiscoverability"; +import type { PluginSharePrincipal } from "./PluginSharePrincipal"; + +export type PluginShareUpdateTargetsResponse = { principals: Array, discoverability: PluginShareDiscoverability, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts index fe9e63703d..268349cb9b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts @@ -5,10 +5,23 @@ import type { PluginAuthPolicy } from "./PluginAuthPolicy"; import type { PluginAvailability } from "./PluginAvailability"; import type { PluginInstallPolicy } from "./PluginInstallPolicy"; import type { PluginInterface } from "./PluginInterface"; +import type { PluginShareContext } from "./PluginShareContext"; import type { PluginSource } from "./PluginSource"; -export type PluginSummary = { id: string, name: string, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, authPolicy: PluginAuthPolicy, +export type PluginSummary = { id: string, +/** + * Backend remote plugin identifier when available. + */ +remotePluginId: string | null, +/** + * Version of the locally materialized plugin package when available. + */ +localVersion: string | null, name: string, +/** + * Remote sharing context associated with this plugin when available. + */ +shareContext: PluginShareContext | null, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, authPolicy: PluginAuthPolicy, /** * Availability state for installing and using the plugin. */ -availability: PluginAvailability, interface: PluginInterface | null, }; +availability: PluginAvailability, interface: PluginInterface | null, keywords: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ProcessExitedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ProcessExitedNotification.ts new file mode 100644 index 0000000000..0d82633421 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ProcessExitedNotification.ts @@ -0,0 +1,42 @@ +// 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. + +/** + * Final process exit notification for `process/spawn`. + */ +export type ProcessExitedNotification = { +/** + * Client-supplied, connection-scoped `processHandle` from `process/spawn`. + */ +processHandle: string, +/** + * Process exit code. + */ +exitCode: number, +/** + * Buffered stdout capture. + * + * Empty when stdout was streamed via `process/outputDelta`. + */ +stdout: string, +/** + * Whether stdout reached `outputBytesCap`. + * + * In streaming mode, stdout is empty and cap state is also reported on the + * final stdout `process/outputDelta` notification. + */ +stdoutCapReached: boolean, +/** + * Buffered stderr capture. + * + * Empty when stderr was streamed via `process/outputDelta`. + */ +stderr: string, +/** + * Whether stderr reached `outputBytesCap`. + * + * In streaming mode, stderr is empty and cap state is also reported on the + * final stderr `process/outputDelta` notification. + */ +stderrCapReached: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ProcessOutputDeltaNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ProcessOutputDeltaNotification.ts new file mode 100644 index 0000000000..46369e396a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ProcessOutputDeltaNotification.ts @@ -0,0 +1,26 @@ +// 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 { ProcessOutputStream } from "./ProcessOutputStream"; + +/** + * Base64-encoded output chunk emitted for a streaming `process/spawn` request. + */ +export type ProcessOutputDeltaNotification = { +/** + * Client-supplied, connection-scoped `processHandle` from `process/spawn`. + */ +processHandle: string, +/** + * Output stream this chunk belongs to. + */ +stream: ProcessOutputStream, +/** + * Base64-encoded output bytes. + */ +deltaBase64: string, +/** + * True on the final streamed chunk for this stream when output was + * truncated by `outputBytesCap`. + */ +capReached: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeyAlgorithm.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ProcessOutputStream.ts similarity index 56% rename from codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeyAlgorithm.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/ProcessOutputStream.ts index 6809c41eb5..1bb550d90d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/DeviceKeyAlgorithm.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ProcessOutputStream.ts @@ -3,6 +3,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * Device-key algorithm reported at enrollment and signing boundaries. + * Stream label for `process/outputDelta` notifications. */ -export type DeviceKeyAlgorithm = "ecdsa_p256_sha256"; +export type ProcessOutputStream = "stdout" | "stderr"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ProcessTerminalSize.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ProcessTerminalSize.ts new file mode 100644 index 0000000000..1c4b467038 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ProcessTerminalSize.ts @@ -0,0 +1,16 @@ +// 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. + +/** + * PTY size in character cells for `process/spawn` PTY sessions. + */ +export type ProcessTerminalSize = { +/** + * Terminal height in character cells. + */ +rows: number, +/** + * Terminal width in character cells. + */ +cols: number, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts index 7afe3e0c54..d05038701c 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ProfileV2.ts @@ -3,7 +3,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; -import type { ServiceTier } from "../ServiceTier"; import type { Verbosity } from "../Verbosity"; import type { WebSearchMode } from "../WebSearchMode"; import type { JsonValue } from "../serde_json/JsonValue"; @@ -16,4 +15,4 @@ export type ProfileV2 = {model: string | null, model_provider: string | null, ap * are routed for review. If omitted, the enclosing config default is * used. */ -approvals_reviewer: ApprovalsReviewer | null, service_tier: ServiceTier | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, chatgpt_base_url: string | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); +approvals_reviewer: ApprovalsReviewer | null, service_tier: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, chatgpt_base_url: string | null} & ({ [key in string]?: number | string | boolean | Array | { [key in string]?: JsonValue } | null }); diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlClientConnectionAudience.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlClientConnectionAudience.ts deleted file mode 100644 index e4d41ff4c2..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlClientConnectionAudience.ts +++ /dev/null @@ -1,8 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Audience for a remote-control client connection device-key proof. - */ -export type RemoteControlClientConnectionAudience = "remote_control_client_websocket"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlClientEnrollmentAudience.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlClientEnrollmentAudience.ts deleted file mode 100644 index b65fb3d11b..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlClientEnrollmentAudience.ts +++ /dev/null @@ -1,8 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Audience for a remote-control client enrollment device-key proof. - */ -export type RemoteControlClientEnrollmentAudience = "remote_control_client_enrollment"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts index 16a9138556..8c63ab9029 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts @@ -4,6 +4,6 @@ import type { RemoteControlConnectionStatus } from "./RemoteControlConnectionStatus"; /** - * Current remote-control connection status and environment id exposed to clients. + * Current remote-control connection status and remote identity exposed to clients. */ -export type RemoteControlStatusChangedNotification = { status: RemoteControlConnectionStatus, environmentId: string | null, }; +export type RemoteControlStatusChangedNotification = { status: RemoteControlConnectionStatus, installationId: string, environmentId: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsListParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsListParams.ts index ad714a3297..4adeb38b3b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsListParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SkillsListParams.ts @@ -1,7 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { SkillsListExtraRootsForCwd } from "./SkillsListExtraRootsForCwd"; export type SkillsListParams = { /** @@ -11,8 +10,4 @@ cwds?: Array, /** * When true, bypass the skills cache and re-scan skills from disk. */ -forceReload?: boolean, -/** - * Optional per-cwd extra roots to scan as user-scoped skills. - */ -perCwdExtraUserRoots?: Array | null, }; +forceReload?: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts index 8c4c9394bf..d917094e36 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Thread.ts @@ -4,10 +4,15 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { GitInfo } from "./GitInfo"; import type { SessionSource } from "./SessionSource"; +import type { ThreadSource } from "./ThreadSource"; import type { ThreadStatus } from "./ThreadStatus"; import type { Turn } from "./Turn"; export type Thread = { id: string, +/** + * Session id shared by threads that belong to the same session tree. + */ +sessionId: string, /** * Source thread id when this thread was created by forking another thread. */ @@ -52,6 +57,10 @@ cliVersion: string, * Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). */ source: SessionSource, +/** + * Optional analytics source classification for this thread. + */ +threadSource: ThreadSource | null, /** * Optional random unique nickname assigned to an AgentControl-spawned sub-agent. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts index ba7119e9ed..6076a4bb14 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts @@ -1,11 +1,11 @@ // 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 { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxMode } from "./SandboxMode"; +import type { ThreadSource } from "./ThreadSource"; /** * There are two ways to fork a thread: @@ -19,8 +19,11 @@ import type { SandboxMode } from "./SandboxMode"; export type ThreadForkParams = {threadId: string, /** * Configuration overrides for the forked thread, if any. */ -model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** +model?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean}; +approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /** + * Optional client-supplied analytics source classification for this forked thread. + */ +threadSource?: ThreadSource | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index ddcef104e9..c44533ec1a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -3,13 +3,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { ReasoningEffort } from "../ReasoningEffort"; -import type { ServiceTier } from "../ServiceTier"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadForkResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** +export type ThreadForkResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** * Instruction source files currently loaded for this thread. */ instructionSources: Array, approvalPolicy: AskForApproval, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts index ac8b1e293b..6d1dbdca4f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts @@ -2,7 +2,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Personality } from "../Personality"; -import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -22,7 +21,7 @@ import type { SandboxMode } from "./SandboxMode"; export type ThreadResumeParams = {threadId: string, /** * Configuration overrides for the resumed thread, if any. */ -model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** +model?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** * Override where approval requests are routed for review on this thread * and subsequent turns. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts index f7627c07ae..f91756c7c6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts @@ -3,13 +3,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { ReasoningEffort } from "../ReasoningEffort"; -import type { ServiceTier } from "../ServiceTier"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadResumeResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** +export type ThreadResumeResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** * Instruction source files currently loaded for this thread. */ instructionSources: Array, approvalPolicy: AskForApproval, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSource.ts new file mode 100644 index 0000000000..8f55524801 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadSource.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 ThreadSource = "user" | "subagent" | "memory_consolidation"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts index 374ac2e681..30509ef6cb 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartParams.ts @@ -2,15 +2,18 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { Personality } from "../Personality"; -import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxMode } from "./SandboxMode"; +import type { ThreadSource } from "./ThreadSource"; import type { ThreadStartSource } from "./ThreadStartSource"; -export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, serviceTier?: ServiceTier | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** +export type ThreadStartParams = {model?: string | null, modelProvider?: string | null, serviceTier?: string | null | null, cwd?: string | null, approvalPolicy?: AskForApproval | null, /** * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, sessionStartSource?: ThreadStartSource | null}; +approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, serviceName?: string | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null, ephemeral?: boolean | null, sessionStartSource?: ThreadStartSource | null, /** + * Optional client-supplied analytics source classification for this thread. + */ +threadSource?: ThreadSource | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts index ce28a4a1d7..9573bd7dee 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts @@ -3,13 +3,12 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { ReasoningEffort } from "../ReasoningEffort"; -import type { ServiceTier } from "../ServiceTier"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; import type { SandboxPolicy } from "./SandboxPolicy"; import type { Thread } from "./Thread"; -export type ThreadStartResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: AbsolutePathBuf, /** +export type ThreadStartResponse = {thread: Thread, model: string, modelProvider: string, serviceTier: string | null, cwd: AbsolutePathBuf, /** * Instruction source files currently loaded for this thread. */ instructionSources: Array, approvalPolicy: AskForApproval, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts index 844c09c4fd..6505ec345f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Turn.ts @@ -3,15 +3,18 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { ThreadItem } from "./ThreadItem"; import type { TurnError } from "./TurnError"; +import type { TurnItemsView } from "./TurnItemsView"; import type { TurnStatus } from "./TurnStatus"; export type Turn = { id: string, /** - * Only populated on a `thread/resume` or `thread/fork` response. - * For all other responses and notifications returning a Turn, - * the items field will be an empty list. + * Thread items currently included in this turn payload. */ -items: Array, status: TurnStatus, +items: Array, +/** + * Describes how much of `items` has been loaded for this turn. + */ +itemsView: TurnItemsView, status: TurnStatus, /** * Only populated when the Turn's status is failed. */ diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnItemsView.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnItemsView.ts new file mode 100644 index 0000000000..9056923065 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnItemsView.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 TurnItemsView = "notLoaded" | "summary" | "full"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index 4af17115c8..b04919d86b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -4,7 +4,6 @@ import type { Personality } from "../Personality"; import type { ReasoningEffort } from "../ReasoningEffort"; import type { ReasoningSummary } from "../ReasoningSummary"; -import type { ServiceTier } from "../ServiceTier"; import type { JsonValue } from "../serde_json/JsonValue"; import type { ApprovalsReviewer } from "./ApprovalsReviewer"; import type { AskForApproval } from "./AskForApproval"; @@ -30,7 +29,7 @@ sandboxPolicy?: SandboxPolicy | null, /** model?: string | null, /** * Override the service tier for this turn and subsequent turns. */ -serviceTier?: ServiceTier | null | null, /** +serviceTier?: string | null | null, /** * Override the reasoning effort for this turn and subsequent turns. */ effort?: ReasoningEffort | null, /** diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxReadiness.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxReadiness.ts new file mode 100644 index 0000000000..41b1161acf --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxReadiness.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 WindowsSandboxReadiness = "ready" | "notConfigured" | "updateRequired"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxReadinessResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxReadinessResponse.ts new file mode 100644 index 0000000000..bc42a1d962 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WindowsSandboxReadinessResponse.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { WindowsSandboxReadiness } from "./WindowsSandboxReadiness"; + +export type WindowsSandboxReadinessResponse = { status: WindowsSandboxReadiness, }; 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 d369ba3423..a6b961366e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -28,6 +28,8 @@ export type { AppsDefaultConfig } from "./AppsDefaultConfig"; export type { AppsListParams } from "./AppsListParams"; export type { AppsListResponse } from "./AppsListResponse"; export type { AskForApproval } from "./AskForApproval"; +export type { AttestationGenerateParams } from "./AttestationGenerateParams"; +export type { AttestationGenerateResponse } from "./AttestationGenerateResponse"; export type { AutoReviewDecisionSource } from "./AutoReviewDecisionSource"; export type { ByteRange } from "./ByteRange"; export type { CancelLoginAccountParams } from "./CancelLoginAccountParams"; @@ -79,16 +81,6 @@ export type { ConfiguredHookMatcherGroup } from "./ConfiguredHookMatcherGroup"; export type { ContextCompactedNotification } from "./ContextCompactedNotification"; export type { CreditsSnapshot } from "./CreditsSnapshot"; export type { DeprecationNoticeNotification } from "./DeprecationNoticeNotification"; -export type { DeviceKeyAlgorithm } from "./DeviceKeyAlgorithm"; -export type { DeviceKeyCreateParams } from "./DeviceKeyCreateParams"; -export type { DeviceKeyCreateResponse } from "./DeviceKeyCreateResponse"; -export type { DeviceKeyProtectionClass } from "./DeviceKeyProtectionClass"; -export type { DeviceKeyProtectionPolicy } from "./DeviceKeyProtectionPolicy"; -export type { DeviceKeyPublicParams } from "./DeviceKeyPublicParams"; -export type { DeviceKeyPublicResponse } from "./DeviceKeyPublicResponse"; -export type { DeviceKeySignParams } from "./DeviceKeySignParams"; -export type { DeviceKeySignPayload } from "./DeviceKeySignPayload"; -export type { DeviceKeySignResponse } from "./DeviceKeySignResponse"; export type { DynamicToolCallOutputContentItem } from "./DynamicToolCallOutputContentItem"; export type { DynamicToolCallParams } from "./DynamicToolCallParams"; export type { DynamicToolCallResponse } from "./DynamicToolCallResponse"; @@ -168,6 +160,7 @@ export type { HookRunSummary } from "./HookRunSummary"; export type { HookScope } from "./HookScope"; export type { HookSource } from "./HookSource"; export type { HookStartedNotification } from "./HookStartedNotification"; +export type { HookTrustStatus } from "./HookTrustStatus"; export type { HooksListEntry } from "./HooksListEntry"; export type { HooksListParams } from "./HooksListParams"; export type { HooksListResponse } from "./HooksListResponse"; @@ -245,6 +238,7 @@ export type { ModelProviderCapabilitiesReadParams } from "./ModelProviderCapabil export type { ModelProviderCapabilitiesReadResponse } from "./ModelProviderCapabilitiesReadResponse"; export type { ModelRerouteReason } from "./ModelRerouteReason"; export type { ModelReroutedNotification } from "./ModelReroutedNotification"; +export type { ModelServiceTier } from "./ModelServiceTier"; export type { ModelUpgradeInfo } from "./ModelUpgradeInfo"; export type { ModelVerification } from "./ModelVerification"; export type { ModelVerificationNotification } from "./ModelVerificationNotification"; @@ -272,22 +266,34 @@ export type { PlanDeltaNotification } from "./PlanDeltaNotification"; export type { PluginAuthPolicy } from "./PluginAuthPolicy"; export type { PluginAvailability } from "./PluginAvailability"; export type { PluginDetail } from "./PluginDetail"; +export type { PluginHookSummary } from "./PluginHookSummary"; export type { PluginInstallParams } from "./PluginInstallParams"; export type { PluginInstallPolicy } from "./PluginInstallPolicy"; export type { PluginInstallResponse } from "./PluginInstallResponse"; export type { PluginInterface } from "./PluginInterface"; +export type { PluginListMarketplaceKind } from "./PluginListMarketplaceKind"; export type { PluginListParams } from "./PluginListParams"; export type { PluginListResponse } from "./PluginListResponse"; export type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry"; export type { PluginReadParams } from "./PluginReadParams"; export type { PluginReadResponse } from "./PluginReadResponse"; +export type { PluginShareContext } from "./PluginShareContext"; export type { PluginShareDeleteParams } from "./PluginShareDeleteParams"; export type { PluginShareDeleteResponse } from "./PluginShareDeleteResponse"; +export type { PluginShareDiscoverability } from "./PluginShareDiscoverability"; export type { PluginShareListItem } from "./PluginShareListItem"; export type { PluginShareListParams } from "./PluginShareListParams"; export type { PluginShareListResponse } from "./PluginShareListResponse"; +export type { PluginSharePrincipal } from "./PluginSharePrincipal"; +export type { PluginSharePrincipalRole } from "./PluginSharePrincipalRole"; +export type { PluginSharePrincipalType } from "./PluginSharePrincipalType"; export type { PluginShareSaveParams } from "./PluginShareSaveParams"; export type { PluginShareSaveResponse } from "./PluginShareSaveResponse"; +export type { PluginShareTarget } from "./PluginShareTarget"; +export type { PluginShareTargetRole } from "./PluginShareTargetRole"; +export type { PluginShareUpdateDiscoverability } from "./PluginShareUpdateDiscoverability"; +export type { PluginShareUpdateTargetsParams } from "./PluginShareUpdateTargetsParams"; +export type { PluginShareUpdateTargetsResponse } from "./PluginShareUpdateTargetsResponse"; export type { PluginSkillReadParams } from "./PluginSkillReadParams"; export type { PluginSkillReadResponse } from "./PluginSkillReadResponse"; export type { PluginSource } from "./PluginSource"; @@ -295,6 +301,10 @@ export type { PluginSummary } from "./PluginSummary"; export type { PluginUninstallParams } from "./PluginUninstallParams"; export type { PluginUninstallResponse } from "./PluginUninstallResponse"; export type { PluginsMigration } from "./PluginsMigration"; +export type { ProcessExitedNotification } from "./ProcessExitedNotification"; +export type { ProcessOutputDeltaNotification } from "./ProcessOutputDeltaNotification"; +export type { ProcessOutputStream } from "./ProcessOutputStream"; +export type { ProcessTerminalSize } from "./ProcessTerminalSize"; export type { ProfileV2 } from "./ProfileV2"; export type { RateLimitReachedType } from "./RateLimitReachedType"; export type { RateLimitSnapshot } from "./RateLimitSnapshot"; @@ -304,8 +314,6 @@ export type { ReasoningEffortOption } from "./ReasoningEffortOption"; export type { ReasoningSummaryPartAddedNotification } from "./ReasoningSummaryPartAddedNotification"; export type { ReasoningSummaryTextDeltaNotification } from "./ReasoningSummaryTextDeltaNotification"; export type { ReasoningTextDeltaNotification } from "./ReasoningTextDeltaNotification"; -export type { RemoteControlClientConnectionAudience } from "./RemoteControlClientConnectionAudience"; -export type { RemoteControlClientEnrollmentAudience } from "./RemoteControlClientEnrollmentAudience"; export type { RemoteControlConnectionStatus } from "./RemoteControlConnectionStatus"; export type { RemoteControlStatusChangedNotification } from "./RemoteControlStatusChangedNotification"; export type { RequestPermissionProfile } from "./RequestPermissionProfile"; @@ -333,7 +341,6 @@ export type { SkillsChangedNotification } from "./SkillsChangedNotification"; export type { SkillsConfigWriteParams } from "./SkillsConfigWriteParams"; export type { SkillsConfigWriteResponse } from "./SkillsConfigWriteResponse"; export type { SkillsListEntry } from "./SkillsListEntry"; -export type { SkillsListExtraRootsForCwd } from "./SkillsListExtraRootsForCwd"; export type { SkillsListParams } from "./SkillsListParams"; export type { SkillsListResponse } from "./SkillsListResponse"; export type { SortDirection } from "./SortDirection"; @@ -390,6 +397,7 @@ export type { ThreadSetNameResponse } from "./ThreadSetNameResponse"; export type { ThreadShellCommandParams } from "./ThreadShellCommandParams"; export type { ThreadShellCommandResponse } from "./ThreadShellCommandResponse"; export type { ThreadSortKey } from "./ThreadSortKey"; +export type { ThreadSource } from "./ThreadSource"; export type { ThreadSourceKind } from "./ThreadSourceKind"; export type { ThreadStartParams } from "./ThreadStartParams"; export type { ThreadStartResponse } from "./ThreadStartResponse"; @@ -419,6 +427,7 @@ export type { TurnEnvironmentParams } from "./TurnEnvironmentParams"; export type { TurnError } from "./TurnError"; export type { TurnInterruptParams } from "./TurnInterruptParams"; export type { TurnInterruptResponse } from "./TurnInterruptResponse"; +export type { TurnItemsView } from "./TurnItemsView"; export type { TurnPlanStep } from "./TurnPlanStep"; export type { TurnPlanStepStatus } from "./TurnPlanStepStatus"; export type { TurnPlanUpdatedNotification } from "./TurnPlanUpdatedNotification"; @@ -431,6 +440,8 @@ export type { TurnSteerResponse } from "./TurnSteerResponse"; export type { UserInput } from "./UserInput"; export type { WarningNotification } from "./WarningNotification"; export type { WebSearchAction } from "./WebSearchAction"; +export type { WindowsSandboxReadiness } from "./WindowsSandboxReadiness"; +export type { WindowsSandboxReadinessResponse } from "./WindowsSandboxReadinessResponse"; export type { WindowsSandboxSetupCompletedNotification } from "./WindowsSandboxSetupCompletedNotification"; export type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; export type { WindowsSandboxSetupStartParams } from "./WindowsSandboxSetupStartParams"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index c5a7d61f01..ae00b08b73 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -77,9 +77,11 @@ macro_rules! experimental_type_entry { #[derive(Debug, Clone, PartialEq, Eq)] pub enum ClientRequestSerializationScope { Global(&'static str), + GlobalSharedRead(&'static str), Thread { thread_id: String }, ThreadPath { path: PathBuf }, CommandExecProcess { process_id: String }, + Process { process_handle: String }, FuzzyFileSearchSession { session_id: String }, FsWatch { watch_id: String }, McpOauth { server_name: String }, @@ -92,6 +94,9 @@ macro_rules! serialization_scope_expr { ($actual_params:ident, global($key:literal)) => { Some(ClientRequestSerializationScope::Global($key)) }; + ($actual_params:ident, global_shared_read($key:literal)) => { + Some(ClientRequestSerializationScope::GlobalSharedRead($key)) + }; ($actual_params:ident, thread_id($params:ident . $field:ident)) => { Some(ClientRequestSerializationScope::Thread { thread_id: $actual_params.$field.clone(), @@ -127,6 +132,11 @@ macro_rules! serialization_scope_expr { process_id: $actual_params.$field.clone(), }) }; + ($actual_params:ident, process_handle($params:ident . $field:ident)) => { + Some(ClientRequestSerializationScope::Process { + process_handle: $actual_params.$field.clone(), + }) + }; ($actual_params:ident, fuzzy_session_id($params:ident . $field:ident)) => { Some(ClientRequestSerializationScope::FuzzyFileSearchSession { session_id: $actual_params.$field.clone(), @@ -571,6 +581,13 @@ client_request_definitions! { serialization: None, response: v2::ThreadTurnsListResponse, }, + #[experimental("thread/turns/items/list")] + ThreadTurnsItemsList => "thread/turns/items/list" { + params: v2::ThreadTurnsItemsListParams, + // Explicitly concurrent: this primarily reads append-only rollout storage. + serialization: None, + response: v2::ThreadTurnsItemsListResponse, + }, /// Append raw Responses API items to the thread history without starting a user turn. ThreadInjectItems => "thread/inject_items" { params: v2::ThreadInjectItemsParams, @@ -579,7 +596,7 @@ client_request_definitions! { }, SkillsList => "skills/list" { params: v2::SkillsListParams, - serialization: global("config"), + serialization: global_shared_read("config"), response: v2::SkillsListResponse, }, HooksList => "hooks/list" { @@ -604,7 +621,7 @@ client_request_definitions! { }, PluginList => "plugin/list" { params: v2::PluginListParams, - serialization: global("config"), + serialization: global_shared_read("config"), response: v2::PluginListResponse, }, PluginRead => "plugin/read" { @@ -622,6 +639,11 @@ client_request_definitions! { serialization: global("config"), response: v2::PluginShareSaveResponse, }, + PluginShareUpdateTargets => "plugin/share/updateTargets" { + params: v2::PluginShareUpdateTargetsParams, + serialization: global("config"), + response: v2::PluginShareUpdateTargetsResponse, + }, PluginShareList => "plugin/share/list" { params: v2::PluginShareListParams, serialization: global("config"), @@ -637,21 +659,6 @@ client_request_definitions! { serialization: None, response: v2::AppsListResponse, }, - DeviceKeyCreate => "device/key/create" { - params: v2::DeviceKeyCreateParams, - serialization: global("device-key"), - response: v2::DeviceKeyCreateResponse, - }, - DeviceKeyPublic => "device/key/public" { - params: v2::DeviceKeyPublicParams, - serialization: global("device-key"), - response: v2::DeviceKeyPublicResponse, - }, - DeviceKeySign => "device/key/sign" { - params: v2::DeviceKeySignParams, - serialization: global("device-key"), - response: v2::DeviceKeySignResponse, - }, // File system requests are intentionally concurrent. Desktop already treats local // file system operations as concurrent, and app-server remote fs mirrors that model. FsReadFile => "fs/readFile" { @@ -801,6 +808,13 @@ client_request_definitions! { serialization: None, response: v2::MockExperimentalMethodResponse, }, + #[experimental("environment/add")] + /// Adds or replaces a remote environment by id for later selection. + EnvironmentAdd => "environment/add" { + params: v2::EnvironmentAddParams, + serialization: global("environment"), + response: v2::EnvironmentAddResponse, + }, McpServerOauthLogin => "mcpServer/oauth/login" { params: v2::McpServerOauthLoginParams, @@ -837,6 +851,11 @@ client_request_definitions! { serialization: global("windows-sandbox-setup"), response: v2::WindowsSandboxSetupStartResponse, }, + WindowsSandboxReadiness => "windowsSandbox/readiness" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: global("config"), + response: v2::WindowsSandboxReadinessResponse, + }, LoginAccount => "account/login/start" { params: v2::LoginAccountParams, @@ -900,10 +919,38 @@ client_request_definitions! { serialization: command_process_id(params.process_id), response: v2::CommandExecResizeResponse, }, + #[experimental("process/spawn")] + /// Spawn a standalone process (argv vector) without a Codex sandbox. + ProcessSpawn => "process/spawn" { + params: v2::ProcessSpawnParams, + serialization: process_handle(params.process_handle), + response: v2::ProcessSpawnResponse, + }, + #[experimental("process/writeStdin")] + /// Write stdin bytes to a running `process/spawn` session or close stdin. + ProcessWriteStdin => "process/writeStdin" { + params: v2::ProcessWriteStdinParams, + serialization: process_handle(params.process_handle), + response: v2::ProcessWriteStdinResponse, + }, + #[experimental("process/kill")] + /// Terminate a running `process/spawn` session by client-supplied `processHandle`. + ProcessKill => "process/kill" { + params: v2::ProcessKillParams, + serialization: process_handle(params.process_handle), + response: v2::ProcessKillResponse, + }, + #[experimental("process/resizePty")] + /// Resize a running PTY-backed `process/spawn` session by client-supplied `processHandle`. + ProcessResizePty => "process/resizePty" { + params: v2::ProcessResizePtyParams, + serialization: process_handle(params.process_handle), + response: v2::ProcessResizePtyResponse, + }, ConfigRead => "config/read" { params: v2::ConfigReadParams, - serialization: global("config"), + serialization: global_shared_read("config"), response: v2::ConfigReadResponse, }, ExternalAgentConfigDetect => "externalAgentConfig/detect" { @@ -1272,6 +1319,12 @@ server_request_definitions! { response: v2::ChatgptAuthTokensRefreshResponse, }, + /// Generate a fresh upstream attestation result on demand. + AttestationGenerate => "attestation/generate" { + params: v2::AttestationGenerateParams, + response: v2::AttestationGenerateResponse, + }, + /// DEPRECATED APIs below /// Request to approve a patch. /// This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage). @@ -1401,6 +1454,12 @@ server_notification_definitions! { PlanDelta => "item/plan/delta" (v2::PlanDeltaNotification), /// Stream base64-encoded stdout/stderr chunks for a running `command/exec` session. CommandExecOutputDelta => "command/exec/outputDelta" (v2::CommandExecOutputDeltaNotification), + /// Stream base64-encoded stdout/stderr chunks for a running `process/spawn` session. + #[experimental("process/outputDelta")] + ProcessOutputDelta => "process/outputDelta" (v2::ProcessOutputDeltaNotification), + /// Final exit notification for a `process/spawn` session. + #[experimental("process/exited")] + ProcessExited => "process/exited" (v2::ProcessExitedNotification), CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification), TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification), /// Deprecated legacy apply_patch output stream notification. @@ -1605,6 +1664,30 @@ mod tests { Some(ClientRequestSerializationScope::Global("config")) ); + let skills_list = ClientRequest::SkillsList { + request_id: request_id(), + params: v2::SkillsListParams { + cwds: Vec::new(), + force_reload: false, + }, + }; + assert_eq!( + skills_list.serialization_scope(), + Some(ClientRequestSerializationScope::GlobalSharedRead("config")) + ); + + let plugin_list = ClientRequest::PluginList { + request_id: request_id(), + params: v2::PluginListParams { + cwds: None, + marketplace_kinds: None, + }, + }; + assert_eq!( + plugin_list.serialization_scope(), + Some(ClientRequestSerializationScope::GlobalSharedRead("config")) + ); + let plugin_uninstall = ClientRequest::PluginUninstall { request_id: request_id(), params: v2::PluginUninstallParams { @@ -1655,7 +1738,7 @@ mod tests { }; assert_eq!( config_read.serialization_scope(), - Some(ClientRequestSerializationScope::Global("config")) + Some(ClientRequestSerializationScope::GlobalSharedRead("config")) ); let account_read = ClientRequest::GetAccount { @@ -1710,19 +1793,6 @@ mod tests { Some(ClientRequestSerializationScope::Global("config")) ); - let device_key_create = ClientRequest::DeviceKeyCreate { - request_id: request_id(), - params: v2::DeviceKeyCreateParams { - protection_policy: None, - account_user_id: "user".to_string(), - client_id: "client".to_string(), - }, - }; - assert_eq!( - device_key_create.serialization_scope(), - Some(ClientRequestSerializationScope::Global("device-key")) - ); - let add_credits_nudge = ClientRequest::SendAddCreditsNudgeEmail { request_id: request_id(), params: v2::SendAddCreditsNudgeEmailParams { @@ -1733,6 +1803,18 @@ mod tests { add_credits_nudge.serialization_scope(), Some(ClientRequestSerializationScope::Global("account-auth")) ); + + let environment_add = ClientRequest::EnvironmentAdd { + request_id: request_id(), + params: v2::EnvironmentAddParams { + environment_id: "remote-a".to_string(), + exec_server_url: "ws://127.0.0.1:8765".to_string(), + }, + }; + assert_eq!( + environment_add.serialization_scope(), + Some(ClientRequestSerializationScope::Global("environment")) + ); } #[test] @@ -1792,10 +1874,23 @@ mod tests { cursor: None, limit: None, sort_direction: None, + items_view: None, }, }; assert_eq!(thread_turns_list.serialization_scope(), None); + let thread_turns_items_list = ClientRequest::ThreadTurnsItemsList { + request_id: request_id(), + params: v2::ThreadTurnsItemsListParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + cursor: None, + limit: None, + sort_direction: None, + }, + }; + assert_eq!(thread_turns_items_list.serialization_scope(), None); + let mcp_resource_read = ClientRequest::McpResourceRead { request_id: request_id(), params: v2::McpResourceReadParams { @@ -1840,6 +1935,7 @@ mod tests { }, capabilities: Some(v1::InitializeCapabilities { experimental_api: true, + request_attestation: true, opt_out_notification_methods: Some(vec![ "thread/started".to_string(), "item/agentMessage/delta".to_string(), @@ -1860,6 +1956,7 @@ mod tests { }, "capabilities": { "experimentalApi": true, + "requestAttestation": true, "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" @@ -1885,6 +1982,7 @@ mod tests { }, "capabilities": { "experimentalApi": true, + "requestAttestation": true, "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" @@ -1905,6 +2003,7 @@ mod tests { }, capabilities: Some(v1::InitializeCapabilities { experimental_api: true, + request_attestation: true, opt_out_notification_methods: Some(vec![ "thread/started".to_string(), "item/agentMessage/delta".to_string(), @@ -2021,6 +2120,28 @@ mod tests { Ok(()) } + #[test] + fn serialize_attestation_generate_request() -> Result<()> { + let params = v2::AttestationGenerateParams {}; + let request = ServerRequest::AttestationGenerate { + request_id: RequestId::Integer(9), + params: params.clone(), + }; + assert_eq!( + json!({ + "method": "attestation/generate", + "id": 9, + "params": {} + }), + serde_json::to_value(&request)?, + ); + + let payload = ServerRequestPayload::AttestationGenerate(params); + assert_eq!(request.id(), &RequestId::Integer(9)); + assert_eq!(payload.request_with_id(RequestId::Integer(9)), request); + Ok(()) + } + #[test] fn serialize_server_response() -> Result<()> { let response = ServerResponse::CommandExecutionRequestApproval { @@ -2128,6 +2249,7 @@ mod tests { response: v2::ThreadStartResponse { thread: v2::Thread { id: "67e55044-10b1-426f-9247-bb680e5fe0c8".to_string(), + session_id: "67e55044-10b1-426f-9247-bb680e5fe0c7".to_string(), forked_from_id: None, preview: "first prompt".to_string(), ephemeral: true, @@ -2139,6 +2261,7 @@ mod tests { cwd: cwd.clone(), cli_version: "0.0.0".to_string(), source: v2::SessionSource::Exec, + thread_source: None, agent_nickname: None, agent_role: None, git_info: None, @@ -2168,6 +2291,7 @@ mod tests { "response": { "thread": { "id": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "sessionId": "67e55044-10b1-426f-9247-bb680e5fe0c7", "forkedFromId": null, "preview": "first prompt", "ephemeral": true, @@ -2181,6 +2305,7 @@ mod tests { "cwd": absolute_path_string("tmp"), "cliVersion": "0.0.0", "source": "exec", + "threadSource": null, "agentNickname": null, "agentRole": null, "gitInfo": null, @@ -2472,10 +2597,33 @@ mod tests { Ok(()) } + #[test] + fn serialize_environment_add() -> Result<()> { + let request = ClientRequest::EnvironmentAdd { + request_id: RequestId::Integer(9), + params: v2::EnvironmentAddParams { + environment_id: "remote-a".to_string(), + exec_server_url: "ws://127.0.0.1:8765".to_string(), + }, + }; + assert_eq!( + json!({ + "method": "environment/add", + "id": 9, + "params": { + "environmentId": "remote-a", + "execServerUrl": "ws://127.0.0.1:8765" + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_fs_get_metadata() -> Result<()> { let request = ClientRequest::FsGetMetadata { - request_id: RequestId::Integer(9), + request_id: RequestId::Integer(10), params: v2::FsGetMetadataParams { path: absolute_path("tmp/example"), }, @@ -2483,7 +2631,7 @@ mod tests { assert_eq!( json!({ "method": "fs/getMetadata", - "id": 9, + "id": 10, "params": { "path": absolute_path_string("tmp/example") } @@ -2744,6 +2892,19 @@ mod tests { assert_eq!(reason, Some("mock/experimentalMethod")); } + #[test] + fn environment_add_is_marked_experimental() { + let request = ClientRequest::EnvironmentAdd { + request_id: RequestId::Integer(1), + params: v2::EnvironmentAddParams { + environment_id: "remote-a".to_string(), + exec_server_url: "ws://127.0.0.1:8765".to_string(), + }, + }; + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request); + assert_eq!(reason, Some("environment/add")); + } + #[test] fn command_exec_permission_profile_is_marked_experimental() { let request = ClientRequest::OneOffCommandExec { @@ -2892,6 +3053,7 @@ mod tests { thread_id: "thr_123".to_string(), turn_id: "turn_123".to_string(), item_id: "call_123".to_string(), + started_at_ms: 0, approval_id: None, reason: None, network_approval_context: None, diff --git a/codex-rs/app-server-protocol/src/protocol/event_mapping.rs b/codex-rs/app-server-protocol/src/protocol/event_mapping.rs index 809f08050f..609ca83a5d 100644 --- a/codex-rs/app-server-protocol/src/protocol/event_mapping.rs +++ b/codex-rs/app-server-protocol/src/protocol/event_mapping.rs @@ -12,9 +12,6 @@ use crate::protocol::v2::DynamicToolCallStatus; use crate::protocol::v2::FileChangePatchUpdatedNotification; use crate::protocol::v2::ItemCompletedNotification; use crate::protocol::v2::ItemStartedNotification; -use crate::protocol::v2::McpToolCallError; -use crate::protocol::v2::McpToolCallResult; -use crate::protocol::v2::McpToolCallStatus; use crate::protocol::v2::PlanDeltaNotification; use crate::protocol::v2::ReasoningSummaryPartAddedNotification; use crate::protocol::v2::ReasoningSummaryTextDeltaNotification; @@ -23,7 +20,6 @@ use crate::protocol::v2::TerminalInteractionNotification; use crate::protocol::v2::ThreadItem; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem as CoreDynamicToolCallOutputContentItem; use codex_protocol::protocol::EventMsg; -use serde_json::Value as JsonValue; use std::collections::HashMap; /// Build the v2 app-server notification that directly corresponds to a single core event. @@ -73,64 +69,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id: response.turn_id, item, - }) - } - EventMsg::McpToolCallBegin(begin_event) => { - let item = ThreadItem::McpToolCall { - id: begin_event.call_id, - server: begin_event.invocation.server, - tool: begin_event.invocation.tool, - status: McpToolCallStatus::InProgress, - arguments: begin_event.invocation.arguments.unwrap_or(JsonValue::Null), - mcp_app_resource_uri: begin_event.mcp_app_resource_uri, - result: None, - error: None, - duration_ms: None, - }; - ServerNotification::ItemStarted(ItemStartedNotification { - thread_id, - turn_id, - item, - }) - } - EventMsg::McpToolCallEnd(end_event) => { - let status = if end_event.is_success() { - McpToolCallStatus::Completed - } else { - McpToolCallStatus::Failed - }; - let duration_ms = i64::try_from(end_event.duration.as_millis()).ok(); - let (result, error) = match &end_event.result { - Ok(value) => ( - Some(Box::new(McpToolCallResult { - content: value.content.clone(), - structured_content: value.structured_content.clone(), - meta: value.meta.clone(), - })), - None, - ), - Err(message) => ( - None, - Some(McpToolCallError { - message: message.clone(), - }), - ), - }; - let item = ThreadItem::McpToolCall { - id: end_event.call_id, - server: end_event.invocation.server, - tool: end_event.invocation.tool, - status, - arguments: end_event.invocation.arguments.unwrap_or(JsonValue::Null), - mcp_app_resource_uri: end_event.mcp_app_resource_uri, - result, - error, - duration_ms, - }; - ServerNotification::ItemCompleted(ItemCompletedNotification { - thread_id, - turn_id, - item, + completed_at_ms: response.completed_at_ms, }) } EventMsg::CollabAgentSpawnBegin(begin_event) => { @@ -149,6 +88,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + started_at_ms: begin_event.started_at_ms, }) } EventMsg::CollabAgentSpawnEnd(end_event) => { @@ -187,6 +127,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + completed_at_ms: end_event.completed_at_ms, }) } EventMsg::CollabAgentInteractionBegin(begin_event) => { @@ -206,6 +147,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + started_at_ms: begin_event.started_at_ms, }) } EventMsg::CollabAgentInteractionEnd(end_event) => { @@ -233,6 +175,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + completed_at_ms: end_event.completed_at_ms, }) } EventMsg::CollabWaitingBegin(begin_event) => { @@ -256,6 +199,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + started_at_ms: begin_event.started_at_ms, }) } EventMsg::CollabWaitingEnd(end_event) => { @@ -291,6 +235,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + completed_at_ms: end_event.completed_at_ms, }) } EventMsg::CollabCloseBegin(begin_event) => { @@ -309,6 +254,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + started_at_ms: begin_event.started_at_ms, }) } EventMsg::CollabCloseEnd(end_event) => { @@ -341,6 +287,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + completed_at_ms: end_event.completed_at_ms, }) } EventMsg::CollabResumeBegin(begin_event) => { @@ -359,6 +306,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + started_at_ms: begin_event.started_at_ms, }) } EventMsg::CollabResumeEnd(end_event) => { @@ -391,6 +339,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item, + completed_at_ms: end_event.completed_at_ms, }) } EventMsg::AgentMessageContentDelta(event) => { @@ -440,6 +389,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item: item_started_event.item.into(), + started_at_ms: item_started_event.started_at_ms, }) } EventMsg::ItemCompleted(item_completed_event) => { @@ -447,6 +397,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item: item_completed_event.item.into(), + completed_at_ms: item_completed_event.completed_at_ms, }) } EventMsg::PatchApplyUpdated(event) => { @@ -462,6 +413,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item: build_command_execution_begin_item(&exec_command_begin_event), + started_at_ms: exec_command_begin_event.started_at_ms, }) } EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => { @@ -490,6 +442,7 @@ pub fn item_event_to_server_notification( thread_id, turn_id, item: build_command_execution_end_item(&exec_command_end_event), + completed_at_ms: exec_command_end_event.completed_at_ms, }) } _ => unreachable!("unsupported item event"), @@ -500,17 +453,11 @@ pub fn item_event_to_server_notification( mod tests { use super::*; use codex_protocol::ThreadId; - use codex_protocol::mcp::CallToolResult; use codex_protocol::protocol::CollabResumeBeginEvent; use codex_protocol::protocol::CollabResumeEndEvent; use codex_protocol::protocol::ExecCommandOutputDeltaEvent; use codex_protocol::protocol::ExecOutputStream; - use codex_protocol::protocol::McpInvocation; - use codex_protocol::protocol::McpToolCallBeginEvent; - use codex_protocol::protocol::McpToolCallEndEvent; use pretty_assertions::assert_eq; - use rmcp::model::Content; - use std::time::Duration; fn assert_item_started_server_notification( notification: ServerNotification, @@ -548,6 +495,7 @@ mod tests { fn collab_resume_begin_maps_to_item_started_resume_agent() { let event = CollabResumeBeginEvent { call_id: "call-1".to_string(), + started_at_ms: 123, sender_thread_id: ThreadId::new(), receiver_thread_id: ThreadId::new(), receiver_agent_nickname: None, @@ -564,6 +512,7 @@ mod tests { ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), + started_at_ms: event.started_at_ms, item: ThreadItem::CollabAgentToolCall { id: event.call_id, tool: CollabAgentTool::ResumeAgent, @@ -583,6 +532,7 @@ mod tests { fn collab_resume_end_maps_to_item_completed_resume_agent() { let event = CollabResumeEndEvent { call_id: "call-2".to_string(), + completed_at_ms: 456, sender_thread_id: ThreadId::new(), receiver_thread_id: ThreadId::new(), receiver_agent_nickname: None, @@ -601,6 +551,7 @@ mod tests { ItemCompletedNotification { thread_id: "thread-2".to_string(), turn_id: "turn-2".to_string(), + completed_at_ms: event.completed_at_ms, item: ThreadItem::CollabAgentToolCall { id: event.call_id, tool: CollabAgentTool::ResumeAgent, @@ -621,179 +572,6 @@ mod tests { ); } - #[test] - fn mcp_tool_call_begin_maps_to_item_started_notification_with_args() { - let begin_event = McpToolCallBeginEvent { - call_id: "call_123".to_string(), - invocation: McpInvocation { - server: "codex".to_string(), - tool: "list_mcp_resources".to_string(), - arguments: Some(serde_json::json!({"server": ""})), - }, - mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()), - }; - - let notification = item_event_to_server_notification( - EventMsg::McpToolCallBegin(begin_event.clone()), - "thread-1", - "turn_1", - ); - assert_item_started_server_notification( - notification, - ItemStartedNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn_1".to_string(), - item: ThreadItem::McpToolCall { - id: begin_event.call_id, - server: begin_event.invocation.server, - tool: begin_event.invocation.tool, - status: McpToolCallStatus::InProgress, - arguments: serde_json::json!({"server": ""}), - mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()), - result: None, - error: None, - duration_ms: None, - }, - }, - ); - } - - #[test] - fn mcp_tool_call_begin_maps_to_item_started_notification_without_args() { - let begin_event = McpToolCallBeginEvent { - call_id: "call_456".to_string(), - invocation: McpInvocation { - server: "codex".to_string(), - tool: "list_mcp_resources".to_string(), - arguments: None, - }, - mcp_app_resource_uri: None, - }; - - let notification = item_event_to_server_notification( - EventMsg::McpToolCallBegin(begin_event.clone()), - "thread-2", - "turn_2", - ); - assert_item_started_server_notification( - notification, - ItemStartedNotification { - thread_id: "thread-2".to_string(), - turn_id: "turn_2".to_string(), - item: ThreadItem::McpToolCall { - id: begin_event.call_id, - server: begin_event.invocation.server, - tool: begin_event.invocation.tool, - status: McpToolCallStatus::InProgress, - arguments: JsonValue::Null, - mcp_app_resource_uri: None, - result: None, - error: None, - duration_ms: None, - }, - }, - ); - } - - #[test] - fn mcp_tool_call_end_maps_to_item_completed_notification_on_success() { - let content = vec![ - serde_json::to_value(Content::text("{\"resources\":[]}")) - .expect("content should serialize"), - ]; - let result = CallToolResult { - content: content.clone(), - is_error: Some(false), - structured_content: None, - meta: Some(serde_json::json!({ - "ui/resourceUri": "ui://widget/list-resources.html" - })), - }; - - let end_event = McpToolCallEndEvent { - call_id: "call_789".to_string(), - invocation: McpInvocation { - server: "codex".to_string(), - tool: "list_mcp_resources".to_string(), - arguments: Some(serde_json::json!({"server": ""})), - }, - mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()), - duration: Duration::from_nanos(92708), - result: Ok(result), - }; - - let notification = item_event_to_server_notification( - EventMsg::McpToolCallEnd(end_event.clone()), - "thread-3", - "turn_3", - ); - assert_item_completed_server_notification( - notification, - ItemCompletedNotification { - thread_id: "thread-3".to_string(), - turn_id: "turn_3".to_string(), - item: ThreadItem::McpToolCall { - id: end_event.call_id, - server: end_event.invocation.server, - tool: end_event.invocation.tool, - status: McpToolCallStatus::Completed, - arguments: serde_json::json!({"server": ""}), - mcp_app_resource_uri: Some("ui://widget/list-resources.html".to_string()), - result: Some(Box::new(McpToolCallResult { - content, - structured_content: None, - meta: Some(serde_json::json!({ - "ui/resourceUri": "ui://widget/list-resources.html" - })), - })), - error: None, - duration_ms: Some(0), - }, - }, - ); - } - - #[test] - fn mcp_tool_call_end_maps_to_item_completed_notification_on_error() { - let end_event = McpToolCallEndEvent { - call_id: "call_err".to_string(), - invocation: McpInvocation { - server: "codex".to_string(), - tool: "list_mcp_resources".to_string(), - arguments: None, - }, - mcp_app_resource_uri: None, - duration: Duration::from_millis(1), - result: Err("boom".to_string()), - }; - - let notification = item_event_to_server_notification( - EventMsg::McpToolCallEnd(end_event.clone()), - "thread-4", - "turn_4", - ); - assert_item_completed_server_notification( - notification, - ItemCompletedNotification { - thread_id: "thread-4".to_string(), - turn_id: "turn_4".to_string(), - item: ThreadItem::McpToolCall { - id: end_event.call_id, - server: end_event.invocation.server, - tool: end_event.invocation.tool, - status: McpToolCallStatus::Failed, - arguments: JsonValue::Null, - mcp_app_resource_uri: None, - result: None, - error: Some(McpToolCallError { - message: "boom".to_string(), - }), - duration_ms: Some(1), - }, - }, - ); - } - #[test] fn exec_command_output_delta_maps_to_command_execution_output_delta() { let notification = item_event_to_server_notification( diff --git a/codex-rs/app-server-protocol/src/protocol/item_builders.rs b/codex-rs/app-server-protocol/src/protocol/item_builders.rs index 69ba331ce6..17e0f9aef4 100644 --- a/codex-rs/app-server-protocol/src/protocol/item_builders.rs +++ b/codex-rs/app-server-protocol/src/protocol/item_builders.rs @@ -243,6 +243,7 @@ pub fn guardian_auto_approval_review_notification( thread_id: conversation_id.to_string(), turn_id, review_id: assessment.id.clone(), + started_at_ms: assessment.started_at_ms, target_item_id: assessment.target_item_id.clone(), review, action, @@ -258,6 +259,10 @@ pub fn guardian_auto_approval_review_notification( thread_id: conversation_id.to_string(), turn_id, review_id: assessment.id.clone(), + started_at_ms: assessment.started_at_ms, + completed_at_ms: assessment + .completed_at_ms + .unwrap_or(assessment.started_at_ms), target_item_id: assessment.target_item_id.clone(), decision_source: assessment .decision_source diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 64307c24bf..1121d3a35b 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -17,6 +17,7 @@ use crate::protocol::v2::ThreadItem; use crate::protocol::v2::Turn; use crate::protocol::v2::TurnError as V2TurnError; use crate::protocol::v2::TurnError; +use crate::protocol::v2::TurnItemsView; use crate::protocol::v2::TurnStatus; use crate::protocol::v2::UserInput; use crate::protocol::v2::WebSearchAction; @@ -359,6 +360,7 @@ impl ThreadHistoryBuilder { | codex_protocol::items::TurnItem::ImageView(_) | codex_protocol::items::TurnItem::ImageGeneration(_) | codex_protocol::items::TurnItem::FileChange(_) + | codex_protocol::items::TurnItem::McpToolCall(_) | codex_protocol::items::TurnItem::ContextCompaction(_) => {} } } @@ -382,6 +384,7 @@ impl ThreadHistoryBuilder { | codex_protocol::items::TurnItem::ImageView(_) | codex_protocol::items::TurnItem::ImageGeneration(_) | codex_protocol::items::TurnItem::FileChange(_) + | codex_protocol::items::TurnItem::McpToolCall(_) | codex_protocol::items::TurnItem::ContextCompaction(_) => {} } } @@ -1164,6 +1167,7 @@ impl From for Turn { Self { id: value.id, items: value.items, + items_view: TurnItemsView::Full, error: value.error, status: value.status, started_at: value.started_at, @@ -1178,6 +1182,7 @@ impl From<&PendingTurn> for Turn { Self { id: value.id.clone(), items: value.items.clone(), + items_view: TurnItemsView::Full, error: value.error.clone(), status: value.status.clone(), started_at: value.started_at, @@ -1354,6 +1359,7 @@ mod tests { id: "user-item-id".to_string(), content: Vec::new(), }), + started_at_ms: 0, }), EventMsg::TurnComplete(TurnCompleteEvent { turn_id: turn_id.to_string(), @@ -1450,6 +1456,7 @@ mod tests { started_at: None, completed_at: None, duration_ms: None, + items_view: TurnItemsView::Full, items: vec![ ThreadItem::UserMessage { id: "item-1".into(), @@ -1818,6 +1825,7 @@ mod tests { call_id: "exec-1".into(), process_id: Some("pid-1".into()), turn_id: "turn-1".into(), + completed_at_ms: 0, command: vec!["echo".into(), "hello world".into()], cwd: test_path_buf("/tmp").abs(), parsed_cmd: vec![ParsedCommand::Unknown { @@ -1981,6 +1989,7 @@ mod tests { codex_protocol::dynamic_tools::DynamicToolCallRequest { call_id: "dyn-1".into(), turn_id: "turn-1".into(), + started_at_ms: 0, namespace: Some("codex_app".into()), tool: "lookup_ticket".into(), arguments: serde_json::json!({"id":"ABC-123"}), @@ -1989,6 +1998,7 @@ mod tests { EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent { call_id: "dyn-1".into(), turn_id: "turn-1".into(), + completed_at_ms: 0, namespace: Some("codex_app".into()), tool: "lookup_ticket".into(), arguments: serde_json::json!({"id":"ABC-123"}), @@ -2044,6 +2054,7 @@ mod tests { call_id: "exec-declined".into(), process_id: Some("pid-2".into()), turn_id: "turn-1".into(), + completed_at_ms: 0, command: vec!["ls".into()], cwd: test_path_buf("/tmp").abs(), parsed_cmd: vec![ParsedCommand::Unknown { cmd: "ls".into() }], @@ -2132,6 +2143,8 @@ mod tests { id: "review-guardian-exec".into(), target_item_id: Some("guardian-exec".into()), turn_id: "turn-1".into(), + started_at_ms: 1_000, + completed_at_ms: None, status: GuardianAssessmentStatus::InProgress, risk_level: None, user_authorization: None, @@ -2149,6 +2162,8 @@ mod tests { id: "review-guardian-exec".into(), target_item_id: Some("guardian-exec".into()), turn_id: "turn-1".into(), + started_at_ms: 1_000, + completed_at_ms: Some(1_042), status: GuardianAssessmentStatus::Denied, risk_level: Some(codex_protocol::protocol::GuardianRiskLevel::High), user_authorization: Some(codex_protocol::protocol::GuardianUserAuthorization::Low), @@ -2211,6 +2226,8 @@ mod tests { id: "review-guardian-execve".into(), target_item_id: Some("guardian-execve".into()), turn_id: "turn-1".into(), + started_at_ms: 2_000, + completed_at_ms: None, status: GuardianAssessmentStatus::InProgress, risk_level: None, user_authorization: None, @@ -2291,6 +2308,7 @@ mod tests { call_id: "exec-late".into(), process_id: Some("pid-42".into()), turn_id: "turn-a".into(), + completed_at_ms: 0, command: vec!["echo".into(), "done".into()], cwd: test_path_buf("/tmp").abs(), parsed_cmd: vec![ParsedCommand::Unknown { @@ -2382,6 +2400,7 @@ mod tests { call_id: "exec-unknown-turn".into(), process_id: Some("pid-42".into()), turn_id: "turn-missing".into(), + completed_at_ms: 0, command: vec!["echo".into(), "done".into()], cwd: test_path_buf("/tmp").abs(), parsed_cmd: vec![ParsedCommand::Unknown { @@ -2512,6 +2531,7 @@ mod tests { EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { call_id: "patch-call".into(), turn_id: turn_id.to_string(), + started_at_ms: 0, changes: [( PathBuf::from("README.md"), codex_protocol::protocol::FileChange::Add { @@ -2714,6 +2734,7 @@ mod tests { started_at: None, completed_at: None, duration_ms: None, + items_view: TurnItemsView::Full, items: Vec::new(), }] ); @@ -2730,6 +2751,7 @@ mod tests { }), EventMsg::CollabResumeEnd(codex_protocol::protocol::CollabResumeEndEvent { call_id: "resume-1".into(), + completed_at_ms: 0, sender_thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000001") .expect("valid sender thread id"), receiver_thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000002") @@ -2786,6 +2808,7 @@ mod tests { }), EventMsg::CollabAgentSpawnEnd(codex_protocol::protocol::CollabAgentSpawnEndEvent { call_id: "spawn-1".into(), + completed_at_ms: 0, sender_thread_id, new_thread_id: Some(spawned_thread_id), new_agent_nickname: Some("Scout".into()), @@ -2847,6 +2870,7 @@ mod tests { EventMsg::CollabAgentInteractionBegin( codex_protocol::protocol::CollabAgentInteractionBeginEvent { call_id: "send-1".into(), + started_at_ms: 0, sender_thread_id: sender, receiver_thread_id: receiver, prompt: "new task".into(), @@ -2855,6 +2879,7 @@ mod tests { EventMsg::CollabAgentInteractionEnd( codex_protocol::protocol::CollabAgentInteractionEndEvent { call_id: "send-1".into(), + completed_at_ms: 0, sender_thread_id: sender, receiver_thread_id: receiver, receiver_agent_nickname: None, @@ -2969,6 +2994,7 @@ mod tests { started_at: None, completed_at: None, duration_ms: None, + items_view: TurnItemsView::Full, items: vec![ThreadItem::UserMessage { id: "item-1".into(), content: vec![UserInput::Text { diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index d642e7fab9..95ab710a6b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -46,6 +46,9 @@ pub struct InitializeCapabilities { /// Opt into receiving experimental API methods and fields. #[serde(default)] pub experimental_api: bool, + /// Opt into `attestation/generate` requests for upstream `x-oai-attestation`. + #[serde(default)] + pub request_attestation: bool, /// Exact notification method names that should be suppressed for this /// connection (for example `thread/started`). #[ts(optional = nullable)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs deleted file mode 100644 index 963ac69000..0000000000 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ /dev/null @@ -1,11370 +0,0 @@ -use std::collections::BTreeMap; -use std::collections::HashMap; -use std::num::NonZeroUsize; -use std::path::PathBuf; - -use crate::RequestId; -use crate::protocol::common::AuthMode; -use crate::protocol::item_builders::convert_patch_changes; -use codex_experimental_api_macros::ExperimentalApi; -use codex_protocol::account::PlanType; -use codex_protocol::account::ProviderAccount; -use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; -use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; -use codex_protocol::approvals::GuardianAssessmentAction as CoreGuardianAssessmentAction; -use codex_protocol::approvals::GuardianAssessmentDecisionSource as CoreGuardianAssessmentDecisionSource; -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; -use codex_protocol::approvals::NetworkPolicyRuleAction as CoreNetworkPolicyRuleAction; -use codex_protocol::config_types::ApprovalsReviewer as CoreApprovalsReviewer; -use codex_protocol::config_types::CollaborationMode; -use codex_protocol::config_types::CollaborationModeMask as CoreCollaborationModeMask; -use codex_protocol::config_types::ForcedLoginMethod; -use codex_protocol::config_types::ModeKind; -use codex_protocol::config_types::Personality; -use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::config_types::SandboxMode as CoreSandboxMode; -use codex_protocol::config_types::ServiceTier; -use codex_protocol::config_types::Verbosity; -use codex_protocol::config_types::WebSearchMode; -use codex_protocol::config_types::WebSearchToolConfig; -use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; -use codex_protocol::items::TurnItem as CoreTurnItem; -use codex_protocol::mcp::CallToolResult as CoreMcpCallToolResult; -use codex_protocol::mcp::Resource as McpResource; -pub use codex_protocol::mcp::ResourceContent as McpResourceContent; -use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate; -use codex_protocol::mcp::Tool as McpTool; -use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; -use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; -use codex_protocol::models::ActivePermissionProfile as CoreActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification as CoreActivePermissionProfileModification; -use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; -use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; -use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; -use codex_protocol::models::MessagePhase; -use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; -use codex_protocol::models::PermissionProfile as CorePermissionProfile; -use codex_protocol::models::ResponseItem; -use codex_protocol::openai_models::InputModality; -use codex_protocol::openai_models::ModelAvailabilityNux as CoreModelAvailabilityNux; -use codex_protocol::openai_models::ReasoningEffort; -use codex_protocol::openai_models::default_input_modalities; -use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand; -use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode; -use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath; -use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry; -use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialPath; -use codex_protocol::permissions::NetworkSandboxPolicy as CoreNetworkSandboxPolicy; -use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; -use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; -use codex_protocol::protocol::AgentStatus as CoreAgentStatus; -use codex_protocol::protocol::AskForApproval as CoreAskForApproval; -use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; -use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; -use codex_protocol::protocol::ExecCommandSource as CoreExecCommandSource; -use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus; -use codex_protocol::protocol::GranularApprovalConfig as CoreGranularApprovalConfig; -use codex_protocol::protocol::GuardianRiskLevel as CoreGuardianRiskLevel; -use codex_protocol::protocol::GuardianUserAuthorization as CoreGuardianUserAuthorization; -use codex_protocol::protocol::HookEventName as CoreHookEventName; -use codex_protocol::protocol::HookExecutionMode as CoreHookExecutionMode; -use codex_protocol::protocol::HookHandlerType as CoreHookHandlerType; -use codex_protocol::protocol::HookOutputEntry as CoreHookOutputEntry; -use codex_protocol::protocol::HookOutputEntryKind as CoreHookOutputEntryKind; -use codex_protocol::protocol::HookRunStatus as CoreHookRunStatus; -use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary; -use codex_protocol::protocol::HookScope as CoreHookScope; -use codex_protocol::protocol::HookSource as CoreHookSource; -use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason; -use codex_protocol::protocol::ModelVerification as CoreModelVerification; -use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; -use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind; -use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; -use codex_protocol::protocol::RateLimitReachedType as CoreRateLimitReachedType; -use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; -use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; -use codex_protocol::protocol::RealtimeAudioFrame as CoreRealtimeAudioFrame; -use codex_protocol::protocol::RealtimeConversationVersion; -use codex_protocol::protocol::RealtimeOutputModality; -use codex_protocol::protocol::RealtimeVoice; -use codex_protocol::protocol::RealtimeVoicesList; -use codex_protocol::protocol::ReviewDecision as CoreReviewDecision; -use codex_protocol::protocol::SessionSource as CoreSessionSource; -use codex_protocol::protocol::SkillDependencies as CoreSkillDependencies; -use codex_protocol::protocol::SkillInterface as CoreSkillInterface; -use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; -use codex_protocol::protocol::SkillScope as CoreSkillScope; -use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; -use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; -use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus; -use codex_protocol::protocol::TokenUsage as CoreTokenUsage; -use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; -use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; -use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; -use codex_protocol::user_input::ByteRange as CoreByteRange; -use codex_protocol::user_input::TextElement as CoreTextElement; -use codex_protocol::user_input::UserInput as CoreUserInput; -use codex_utils_absolute_path::AbsolutePathBuf; -use schemars::JsonSchema; -use schemars::r#gen::SchemaGenerator; -use schemars::schema::InstanceType; -use schemars::schema::Metadata; -use schemars::schema::Schema; -use schemars::schema::SchemaObject; -use serde::Deserialize; -use serde::Serialize; -use serde_json::Value as JsonValue; -use serde_with::serde_as; -use thiserror::Error; -use ts_rs::TS; - -// Macro to declare a camelCased API v2 enum mirroring a core enum which -// tends to use either snake_case or kebab-case. -macro_rules! v2_enum_from_core { - ( - $(#[$enum_meta:meta])* - pub enum $Name:ident from $Src:path { - $( $(#[$variant_meta:meta])* $Variant:ident ),+ $(,)? - } - ) => { - #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] - $(#[$enum_meta])* - #[serde(rename_all = "camelCase")] - #[ts(export_to = "v2/")] - pub enum $Name { - $( $(#[$variant_meta])* $Variant ),+ - } - - impl $Name { - pub fn to_core(self) -> $Src { - match self { $( $Name::$Variant => <$Src>::$Variant ),+ } - } - } - - impl From<$Src> for $Name { - fn from(value: $Src) -> Self { - match value { $( <$Src>::$Variant => $Name::$Variant ),+ } - } - } - }; -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum NonSteerableTurnKind { - Review, - Compact, -} - -/// This translation layer make sure that we expose codex error code in camel case. -/// -/// When an upstream HTTP status is available (for example, from the Responses API or a provider), -/// it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CodexErrorInfo { - ContextWindowExceeded, - UsageLimitExceeded, - ServerOverloaded, - CyberPolicy, - HttpConnectionFailed { - #[serde(rename = "httpStatusCode")] - #[ts(rename = "httpStatusCode")] - http_status_code: Option, - }, - /// Failed to connect to the response SSE stream. - ResponseStreamConnectionFailed { - #[serde(rename = "httpStatusCode")] - #[ts(rename = "httpStatusCode")] - http_status_code: Option, - }, - InternalServerError, - Unauthorized, - BadRequest, - ThreadRollbackFailed, - SandboxError, - /// The response SSE stream disconnected in the middle of a turn before completion. - ResponseStreamDisconnected { - #[serde(rename = "httpStatusCode")] - #[ts(rename = "httpStatusCode")] - http_status_code: Option, - }, - /// Reached the retry limit for responses. - ResponseTooManyFailedAttempts { - #[serde(rename = "httpStatusCode")] - #[ts(rename = "httpStatusCode")] - http_status_code: Option, - }, - /// Returned when `turn/start` or `turn/steer` is submitted while the current active turn - /// cannot accept same-turn steering, for example `/review` or manual `/compact`. - ActiveTurnNotSteerable { - #[serde(rename = "turnKind")] - #[ts(rename = "turnKind")] - turn_kind: NonSteerableTurnKind, - }, - Other, -} - -impl From for CodexErrorInfo { - fn from(value: CoreCodexErrorInfo) -> Self { - match value { - CoreCodexErrorInfo::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded, - CoreCodexErrorInfo::UsageLimitExceeded => CodexErrorInfo::UsageLimitExceeded, - CoreCodexErrorInfo::ServerOverloaded => CodexErrorInfo::ServerOverloaded, - CoreCodexErrorInfo::CyberPolicy => CodexErrorInfo::CyberPolicy, - CoreCodexErrorInfo::HttpConnectionFailed { http_status_code } => { - CodexErrorInfo::HttpConnectionFailed { http_status_code } - } - CoreCodexErrorInfo::ResponseStreamConnectionFailed { http_status_code } => { - CodexErrorInfo::ResponseStreamConnectionFailed { http_status_code } - } - CoreCodexErrorInfo::InternalServerError => CodexErrorInfo::InternalServerError, - CoreCodexErrorInfo::Unauthorized => CodexErrorInfo::Unauthorized, - CoreCodexErrorInfo::BadRequest => CodexErrorInfo::BadRequest, - CoreCodexErrorInfo::ThreadRollbackFailed => CodexErrorInfo::ThreadRollbackFailed, - CoreCodexErrorInfo::SandboxError => CodexErrorInfo::SandboxError, - CoreCodexErrorInfo::ResponseStreamDisconnected { http_status_code } => { - CodexErrorInfo::ResponseStreamDisconnected { http_status_code } - } - CoreCodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } => { - CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } - } - CoreCodexErrorInfo::ActiveTurnNotSteerable { turn_kind } => { - CodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - } - } - CoreCodexErrorInfo::Other => CodexErrorInfo::Other, - } - } -} - -impl From for NonSteerableTurnKind { - fn from(value: CoreNonSteerableTurnKind) -> Self { - match value { - CoreNonSteerableTurnKind::Review => Self::Review, - CoreNonSteerableTurnKind::Compact => Self::Compact, - } - } -} - -#[derive( - Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, ExperimentalApi, -)] -#[serde(rename_all = "kebab-case")] -#[ts(rename_all = "kebab-case", export_to = "v2/")] -pub enum AskForApproval { - #[serde(rename = "untrusted")] - #[ts(rename = "untrusted")] - UnlessTrusted, - OnFailure, - OnRequest, - #[experimental("askForApproval.granular")] - Granular { - sandbox_approval: bool, - rules: bool, - #[serde(default)] - skill_approval: bool, - #[serde(default)] - request_permissions: bool, - mcp_elicitations: bool, - }, - Never, -} - -impl AskForApproval { - pub fn to_core(self) -> CoreAskForApproval { - match self { - AskForApproval::UnlessTrusted => CoreAskForApproval::UnlessTrusted, - AskForApproval::OnFailure => CoreAskForApproval::OnFailure, - AskForApproval::OnRequest => CoreAskForApproval::OnRequest, - AskForApproval::Granular { - sandbox_approval, - rules, - skill_approval, - request_permissions, - mcp_elicitations, - } => CoreAskForApproval::Granular(CoreGranularApprovalConfig { - sandbox_approval, - rules, - skill_approval, - request_permissions, - mcp_elicitations, - }), - AskForApproval::Never => CoreAskForApproval::Never, - } - } -} - -impl From for AskForApproval { - fn from(value: CoreAskForApproval) -> Self { - match value { - CoreAskForApproval::UnlessTrusted => AskForApproval::UnlessTrusted, - CoreAskForApproval::OnFailure => AskForApproval::OnFailure, - CoreAskForApproval::OnRequest => AskForApproval::OnRequest, - CoreAskForApproval::Granular(granular_config) => AskForApproval::Granular { - sandbox_approval: granular_config.sandbox_approval, - rules: granular_config.rules, - skill_approval: granular_config.skill_approval, - request_permissions: granular_config.request_permissions, - mcp_elicitations: granular_config.mcp_elicitations, - }, - CoreAskForApproval::Never => AskForApproval::Never, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, TS)] -#[ts( - type = r#""user" | "auto_review" | "guardian_subagent""#, - export_to = "v2/" -)] -/// Configures who approval requests are routed to for review. Examples -/// include sandbox escapes, blocked network access, MCP approval prompts, and -/// ARC escalations. Defaults to `user`. `auto_review` uses a carefully -/// prompted subagent to gather relevant context and apply a risk-based -/// decision framework before approving or denying the request. -pub enum ApprovalsReviewer { - #[serde(rename = "user")] - User, - #[serde(rename = "guardian_subagent", alias = "auto_review")] - AutoReview, -} - -impl JsonSchema for ApprovalsReviewer { - fn schema_name() -> String { - "ApprovalsReviewer".to_string() - } - - fn json_schema(_generator: &mut SchemaGenerator) -> Schema { - string_enum_schema_with_description( - &["user", "auto_review", "guardian_subagent"], - "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", - ) - } -} - -fn string_enum_schema_with_description(values: &[&str], description: &str) -> Schema { - let mut schema = SchemaObject { - instance_type: Some(InstanceType::String.into()), - metadata: Some(Box::new(Metadata { - description: Some(description.to_string()), - ..Default::default() - })), - ..Default::default() - }; - schema.enum_values = Some( - values - .iter() - .map(|value| JsonValue::String((*value).to_string())) - .collect(), - ); - Schema::Object(schema) -} - -impl ApprovalsReviewer { - pub fn to_core(self) -> CoreApprovalsReviewer { - match self { - ApprovalsReviewer::User => CoreApprovalsReviewer::User, - ApprovalsReviewer::AutoReview => CoreApprovalsReviewer::AutoReview, - } - } -} - -impl From for ApprovalsReviewer { - fn from(value: CoreApprovalsReviewer) -> Self { - match value { - CoreApprovalsReviewer::User => ApprovalsReviewer::User, - CoreApprovalsReviewer::AutoReview => ApprovalsReviewer::AutoReview, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "kebab-case")] -#[ts(rename_all = "kebab-case", export_to = "v2/")] -pub enum SandboxMode { - ReadOnly, - WorkspaceWrite, - DangerFullAccess, -} - -impl SandboxMode { - pub fn to_core(self) -> CoreSandboxMode { - match self { - SandboxMode::ReadOnly => CoreSandboxMode::ReadOnly, - SandboxMode::WorkspaceWrite => CoreSandboxMode::WorkspaceWrite, - SandboxMode::DangerFullAccess => CoreSandboxMode::DangerFullAccess, - } - } -} - -impl From for SandboxMode { - fn from(value: CoreSandboxMode) -> Self { - match value { - CoreSandboxMode::ReadOnly => SandboxMode::ReadOnly, - CoreSandboxMode::WorkspaceWrite => SandboxMode::WorkspaceWrite, - CoreSandboxMode::DangerFullAccess => SandboxMode::DangerFullAccess, - } - } -} - -v2_enum_from_core!( - pub enum ReviewDelivery from codex_protocol::protocol::ReviewDelivery { - Inline, Detached - } -); - -v2_enum_from_core!( - pub enum McpAuthStatus from codex_protocol::protocol::McpAuthStatus { - Unsupported, - NotLoggedIn, - BearerToken, - OAuth - } -); - -v2_enum_from_core!( - pub enum ModelRerouteReason from CoreModelRerouteReason { - HighRiskCyberActivity - } -); - -v2_enum_from_core!( - pub enum ModelVerification from CoreModelVerification { - TrustedAccessForCyber - } -); - -v2_enum_from_core!( - pub enum HookEventName from CoreHookEventName { - PreToolUse, PermissionRequest, PostToolUse, SessionStart, UserPromptSubmit, Stop - } -); - -v2_enum_from_core!( - pub enum HookHandlerType from CoreHookHandlerType { - Command, Prompt, Agent - } -); - -v2_enum_from_core!( - pub enum HookExecutionMode from CoreHookExecutionMode { - Sync, Async - } -); - -v2_enum_from_core!( - pub enum HookScope from CoreHookScope { - Thread, Turn - } -); - -v2_enum_from_core!( - pub enum HookSource from CoreHookSource { - System, - User, - Project, - Mdm, - SessionFlags, - Plugin, - CloudRequirements, - LegacyManagedConfigFile, - LegacyManagedConfigMdm, - Unknown, - } -); - -fn default_hook_source() -> HookSource { - HookSource::Unknown -} - -v2_enum_from_core!( - pub enum HookRunStatus from CoreHookRunStatus { - Running, Completed, Failed, Blocked, Stopped - } -); - -v2_enum_from_core!( - pub enum HookOutputEntryKind from CoreHookOutputEntryKind { - Warning, Stop, Feedback, Context, Error - } -); - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase", export_to = "v2/")] -pub enum ThreadStartSource { - Startup, - Clear, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HookOutputEntry { - pub kind: HookOutputEntryKind, - pub text: String, -} - -impl From for HookOutputEntry { - fn from(value: CoreHookOutputEntry) -> Self { - Self { - kind: value.kind.into(), - text: value.text, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HookRunSummary { - pub id: String, - pub event_name: HookEventName, - pub handler_type: HookHandlerType, - pub execution_mode: HookExecutionMode, - pub scope: HookScope, - pub source_path: AbsolutePathBuf, - #[serde(default = "default_hook_source")] - pub source: HookSource, - pub display_order: i64, - pub status: HookRunStatus, - pub status_message: Option, - pub started_at: i64, - pub completed_at: Option, - pub duration_ms: Option, - pub entries: Vec, -} - -impl From for HookRunSummary { - fn from(value: CoreHookRunSummary) -> Self { - Self { - id: value.id, - event_name: value.event_name.into(), - handler_type: value.handler_type.into(), - execution_mode: value.execution_mode.into(), - scope: value.scope.into(), - source_path: value.source_path, - source: value.source.into(), - display_order: value.display_order, - status: value.status.into(), - status_message: value.status_message, - started_at: value.started_at, - completed_at: value.completed_at, - duration_ms: value.duration_ms, - entries: value.entries.into_iter().map(Into::into).collect(), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ConfigLayerSource { - /// Managed preferences layer delivered by MDM (macOS only). - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Mdm { - domain: String, - key: String, - }, - - /// Managed config layer from a file (usually `managed_config.toml`). - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - System { - /// This is the path to the system config.toml file, though it is not - /// guaranteed to exist. - file: AbsolutePathBuf, - }, - - /// User config layer from $CODEX_HOME/config.toml. This layer is special - /// in that it is expected to be: - /// - writable by the user - /// - generally outside the workspace directory - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - User { - /// This is the path to the user's config.toml file, though it is not - /// guaranteed to exist. - file: AbsolutePathBuf, - }, - - /// Path to a .codex/ folder within a project. There could be multiple of - /// these between `cwd` and the project/repo root. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Project { - dot_codex_folder: AbsolutePathBuf, - }, - - /// Session-layer overrides supplied via `-c`/`--config`. - SessionFlags, - - /// `managed_config.toml` was designed to be a config that was loaded - /// as the last layer on top of everything else. This scheme did not quite - /// work out as intended, but we keep this variant as a "best effort" while - /// we phase out `managed_config.toml` in favor of `requirements.toml`. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - LegacyManagedConfigTomlFromFile { - file: AbsolutePathBuf, - }, - - LegacyManagedConfigTomlFromMdm, -} - -impl ConfigLayerSource { - /// A settings from a layer with a higher precedence will override a setting - /// from a layer with a lower precedence. - pub fn precedence(&self) -> i16 { - match self { - ConfigLayerSource::Mdm { .. } => 0, - ConfigLayerSource::System { .. } => 10, - ConfigLayerSource::User { .. } => 20, - ConfigLayerSource::Project { .. } => 25, - ConfigLayerSource::SessionFlags => 30, - ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40, - ConfigLayerSource::LegacyManagedConfigTomlFromMdm => 50, - } - } -} - -/// Compares [ConfigLayerSource] by precedence, so `A < B` means settings from -/// layer `A` will be overridden by settings from layer `B`. -impl PartialOrd for ConfigLayerSource { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.precedence().cmp(&other.precedence())) - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct SandboxWorkspaceWrite { - #[serde(default)] - pub writable_roots: Vec, - #[serde(default)] - pub network_access: bool, - #[serde(default)] - pub exclude_tmpdir_env_var: bool, - #[serde(default)] - pub exclude_slash_tmp: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct ToolsV2 { - pub web_search: Option, - pub view_image: Option, -} - -#[derive(Serialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DynamicToolSpec { - #[ts(optional)] - pub namespace: Option, - pub name: String, - pub description: String, - pub input_schema: JsonValue, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub defer_loading: bool, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct DynamicToolSpecDe { - namespace: Option, - name: String, - description: String, - input_schema: JsonValue, - defer_loading: Option, - expose_to_context: Option, -} - -impl<'de> Deserialize<'de> for DynamicToolSpec { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - let DynamicToolSpecDe { - namespace, - name, - description, - input_schema, - defer_loading, - expose_to_context, - } = DynamicToolSpecDe::deserialize(deserializer)?; - - Ok(Self { - namespace, - name, - description, - input_schema, - defer_loading: defer_loading - .unwrap_or_else(|| expose_to_context.map(|visible| !visible).unwrap_or(false)), - }) - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct ProfileV2 { - pub model: Option, - pub model_provider: Option, - #[experimental(nested)] - pub approval_policy: Option, - /// [UNSTABLE] Optional profile-level override for where approval requests - /// are routed for review. If omitted, the enclosing config default is - /// used. - #[experimental("config/read.approvalsReviewer")] - pub approvals_reviewer: Option, - pub service_tier: Option, - pub model_reasoning_effort: Option, - pub model_reasoning_summary: Option, - pub model_verbosity: Option, - pub web_search: Option, - pub tools: Option, - pub chatgpt_base_url: Option, - #[serde(default, flatten)] - pub additional: HashMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct AnalyticsConfig { - pub enabled: Option, - #[serde(default, flatten)] - pub additional: HashMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub enum AppToolApproval { - Auto, - Prompt, - Approve, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct AppsDefaultConfig { - #[serde(default = "default_enabled")] - pub enabled: bool, - #[serde(default = "default_enabled")] - pub destructive_enabled: bool, - #[serde(default = "default_enabled")] - pub open_world_enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct AppToolConfig { - pub enabled: Option, - pub approval_mode: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct AppToolsConfig { - #[serde(default, flatten)] - pub tools: HashMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct AppConfig { - #[serde(default = "default_enabled")] - pub enabled: bool, - pub destructive_enabled: Option, - pub open_world_enabled: Option, - pub default_tools_approval_mode: Option, - pub default_tools_enabled: Option, - pub tools: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct AppsConfig { - #[serde(default, rename = "_default")] - pub default: Option, - #[serde(default, flatten)] - pub apps: HashMap, -} - -const fn default_enabled() -> bool { - true -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub struct Config { - pub model: Option, - pub review_model: Option, - pub model_context_window: Option, - pub model_auto_compact_token_limit: Option, - pub model_provider: Option, - #[experimental(nested)] - pub approval_policy: Option, - /// [UNSTABLE] Optional default for where approval requests are routed for - /// review. - #[experimental("config/read.approvalsReviewer")] - pub approvals_reviewer: Option, - pub sandbox_mode: Option, - pub sandbox_workspace_write: Option, - pub forced_chatgpt_workspace_id: Option, - pub forced_login_method: Option, - pub web_search: Option, - pub tools: Option, - pub profile: Option, - #[experimental(nested)] - #[serde(default)] - pub profiles: HashMap, - pub instructions: Option, - pub developer_instructions: Option, - pub compact_prompt: Option, - pub model_reasoning_effort: Option, - pub model_reasoning_summary: Option, - pub model_verbosity: Option, - pub service_tier: Option, - pub analytics: Option, - #[experimental("config/read.apps")] - #[serde(default)] - pub apps: Option, - #[serde(default, flatten)] - pub additional: HashMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigLayerMetadata { - pub name: ConfigLayerSource, - pub version: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigLayer { - pub name: ConfigLayerSource, - pub version: String, - pub config: JsonValue, - #[serde(skip_serializing_if = "Option::is_none")] - pub disabled_reason: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum MergeStrategy { - Replace, - Upsert, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum WriteStatus { - Ok, - OkOverridden, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct OverriddenMetadata { - pub message: String, - pub overriding_layer: ConfigLayerMetadata, - pub effective_value: JsonValue, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigWriteResponse { - pub status: WriteStatus, - pub version: String, - /// Canonical path to the config file that was written. - pub file_path: AbsolutePathBuf, - pub overridden_metadata: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ConfigWriteErrorCode { - ConfigLayerReadonly, - ConfigVersionConflict, - ConfigValidationError, - ConfigPathNotFound, - ConfigSchemaUnknownKey, - UserLayerNotFound, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigReadParams { - #[serde(default)] - pub include_layers: bool, - /// Optional working directory to resolve project config layers. If specified, - /// return the effective config as seen from that directory (i.e., including any - /// project layers between `cwd` and the project/repo root). - #[ts(optional = nullable)] - pub cwd: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigReadResponse { - #[experimental(nested)] - pub config: Config, - pub origins: HashMap, - #[serde(skip_serializing_if = "Option::is_none")] - pub layers: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigRequirements { - #[experimental(nested)] - pub allowed_approval_policies: Option>, - #[experimental("configRequirements/read.allowedApprovalsReviewers")] - pub allowed_approvals_reviewers: Option>, - pub allowed_sandbox_modes: Option>, - pub allowed_web_search_modes: Option>, - pub feature_requirements: Option>, - #[experimental("configRequirements/read.hooks")] - pub hooks: Option, - pub enforce_residency: Option, - #[experimental("configRequirements/read.network")] - pub network: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ManagedHooksRequirements { - pub managed_dir: Option, - pub windows_managed_dir: Option, - #[serde(rename = "PreToolUse")] - #[ts(rename = "PreToolUse")] - pub pre_tool_use: Vec, - #[serde(rename = "PermissionRequest")] - #[ts(rename = "PermissionRequest")] - pub permission_request: Vec, - #[serde(rename = "PostToolUse")] - #[ts(rename = "PostToolUse")] - pub post_tool_use: Vec, - #[serde(rename = "SessionStart")] - #[ts(rename = "SessionStart")] - pub session_start: Vec, - #[serde(rename = "UserPromptSubmit")] - #[ts(rename = "UserPromptSubmit")] - pub user_prompt_submit: Vec, - #[serde(rename = "Stop")] - #[ts(rename = "Stop")] - pub stop: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfiguredHookMatcherGroup { - pub matcher: Option, - pub hooks: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type")] -#[ts(tag = "type", export_to = "v2/")] -pub enum ConfiguredHookHandler { - #[serde(rename = "command")] - #[ts(rename = "command")] - Command { - command: String, - #[serde(rename = "timeoutSec")] - #[ts(rename = "timeoutSec")] - timeout_sec: Option, - r#async: bool, - #[serde(rename = "statusMessage")] - #[ts(rename = "statusMessage")] - status_message: Option, - }, - #[serde(rename = "prompt")] - #[ts(rename = "prompt")] - Prompt {}, - #[serde(rename = "agent")] - #[ts(rename = "agent")] - Agent {}, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct NetworkRequirements { - pub enabled: Option, - pub http_port: Option, - pub socks_port: Option, - pub allow_upstream_proxy: Option, - pub dangerously_allow_non_loopback_proxy: Option, - pub dangerously_allow_all_unix_sockets: Option, - /// Canonical network permission map for `experimental_network`. - pub domains: Option>, - /// When true, only managed allowlist entries are respected while managed - /// network enforcement is active. - pub managed_allowed_domains_only: Option, - /// Legacy compatibility view derived from `domains`. - pub allowed_domains: Option>, - /// Legacy compatibility view derived from `domains`. - pub denied_domains: Option>, - /// Canonical unix socket permission map for `experimental_network`. - pub unix_sockets: Option>, - /// Legacy compatibility view derived from `unix_sockets`. - pub allow_unix_sockets: Option>, - pub allow_local_binding: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -pub enum NetworkDomainPermission { - Allow, - Deny, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -pub enum NetworkUnixSocketPermission { - Allow, - None, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ResidencyRequirement { - Us, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigRequirementsReadResponse { - /// Null if no requirements are configured (e.g. no requirements.toml/MDM entries). - #[experimental(nested)] - pub requirements: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema, TS)] -#[ts(export_to = "v2/")] -pub enum ExternalAgentConfigMigrationItemType { - #[serde(rename = "AGENTS_MD")] - #[ts(rename = "AGENTS_MD")] - AgentsMd, - #[serde(rename = "CONFIG")] - #[ts(rename = "CONFIG")] - Config, - #[serde(rename = "SKILLS")] - #[ts(rename = "SKILLS")] - Skills, - #[serde(rename = "PLUGINS")] - #[ts(rename = "PLUGINS")] - Plugins, - #[serde(rename = "MCP_SERVER_CONFIG")] - #[ts(rename = "MCP_SERVER_CONFIG")] - McpServerConfig, - #[serde(rename = "SUBAGENTS")] - #[ts(rename = "SUBAGENTS")] - Subagents, - #[serde(rename = "HOOKS")] - #[ts(rename = "HOOKS")] - Hooks, - #[serde(rename = "COMMANDS")] - #[ts(rename = "COMMANDS")] - Commands, - #[serde(rename = "SESSIONS")] - #[ts(rename = "SESSIONS")] - Sessions, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginsMigration { - #[serde(rename = "marketplaceName")] - #[ts(rename = "marketplaceName")] - pub marketplace_name: String, - #[serde(rename = "pluginNames")] - #[ts(rename = "pluginNames")] - pub plugin_names: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SessionMigration { - pub path: PathBuf, - pub cwd: PathBuf, - pub title: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerMigration { - pub name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HookMigration { - pub name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SubagentMigration { - pub name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandMigration { - pub name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MigrationDetails { - #[serde(default)] - pub plugins: Vec, - #[serde(default)] - pub sessions: Vec, - #[serde(default)] - pub mcp_servers: Vec, - #[serde(default)] - pub hooks: Vec, - #[serde(default)] - pub subagents: Vec, - #[serde(default)] - pub commands: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigMigrationItem { - pub item_type: ExternalAgentConfigMigrationItemType, - pub description: String, - /// Null or empty means home-scoped migration; non-empty means repo-scoped migration. - pub cwd: Option, - pub details: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigDetectResponse { - pub items: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigDetectParams { - /// If true, include detection under the user's home (~/.claude, ~/.codex, etc.). - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub include_home: bool, - /// Zero or more working directories to include for repo-scoped detection. - #[ts(optional = nullable)] - pub cwds: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigImportParams { - pub migration_items: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigImportResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExternalAgentConfigImportCompletedNotification {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigValueWriteParams { - pub key_path: String, - pub value: JsonValue, - pub merge_strategy: MergeStrategy, - /// Path to the config file to write; defaults to the user's `config.toml` when omitted. - #[ts(optional = nullable)] - pub file_path: Option, - #[ts(optional = nullable)] - pub expected_version: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigBatchWriteParams { - pub edits: Vec, - /// Path to the config file to write; defaults to the user's `config.toml` when omitted. - #[ts(optional = nullable)] - pub file_path: Option, - #[ts(optional = nullable)] - pub expected_version: Option, - /// When true, hot-reload the updated user config into all loaded threads after writing. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub reload_user_config: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigEdit { - pub key_path: String, - pub value: JsonValue, - pub merge_strategy: MergeStrategy, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CommandExecutionApprovalDecision { - /// User approved the command. - Accept, - /// User approved the command and future prompts in the same session-scoped - /// approval cache should run without prompting. - AcceptForSession, - /// User approved the command, and wants to apply the proposed execpolicy amendment so future - /// matching commands can run without prompting. - AcceptWithExecpolicyAmendment { - execpolicy_amendment: ExecPolicyAmendment, - }, - /// User chose a persistent network policy rule (allow/deny) for this host. - ApplyNetworkPolicyAmendment { - network_policy_amendment: NetworkPolicyAmendment, - }, - /// User denied the command. The agent will continue the turn. - Decline, - /// User denied the command. The turn will also be immediately interrupted. - Cancel, -} - -impl From for CommandExecutionApprovalDecision { - fn from(value: CoreReviewDecision) -> Self { - match value { - CoreReviewDecision::Approved => Self::Accept, - CoreReviewDecision::ApprovedExecpolicyAmendment { - proposed_execpolicy_amendment, - } => Self::AcceptWithExecpolicyAmendment { - execpolicy_amendment: proposed_execpolicy_amendment.into(), - }, - CoreReviewDecision::ApprovedForSession => Self::AcceptForSession, - CoreReviewDecision::NetworkPolicyAmendment { - network_policy_amendment, - } => Self::ApplyNetworkPolicyAmendment { - network_policy_amendment: network_policy_amendment.into(), - }, - CoreReviewDecision::Abort => Self::Cancel, - CoreReviewDecision::Denied => Self::Decline, - CoreReviewDecision::TimedOut => Self::Decline, - } - } -} - -v2_enum_from_core! { - pub enum NetworkApprovalProtocol from CoreNetworkApprovalProtocol { - Http, - Https, - Socks5Tcp, - Socks5Udp, - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct NetworkApprovalContext { - pub host: String, - pub protocol: NetworkApprovalProtocol, -} - -impl From for NetworkApprovalContext { - fn from(value: CoreNetworkApprovalContext) -> Self { - Self { - host: value.host, - protocol: value.protocol.into(), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AdditionalFileSystemPermissions { - /// This will be removed in favor of `entries`. - pub read: Option>, - /// This will be removed in favor of `entries`. - pub write: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub glob_scan_max_depth: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub entries: Option>, -} - -impl From for AdditionalFileSystemPermissions { - fn from(value: CoreFileSystemPermissions) -> Self { - if let Some((read, write)) = value.legacy_read_write_roots() { - let mut entries = Vec::with_capacity( - read.as_ref().map_or(0, Vec::len) + write.as_ref().map_or(0, Vec::len), - ); - if let Some(paths) = read.as_ref() { - entries.extend(paths.iter().map(|path| FileSystemSandboxEntry { - path: FileSystemPath::Path { path: path.clone() }, - access: FileSystemAccessMode::Read, - })); - } - if let Some(paths) = write.as_ref() { - entries.extend(paths.iter().map(|path| FileSystemSandboxEntry { - path: FileSystemPath::Path { path: path.clone() }, - access: FileSystemAccessMode::Write, - })); - } - Self { - read, - write, - glob_scan_max_depth: None, - entries: Some(entries), - } - } else { - Self { - read: None, - write: None, - glob_scan_max_depth: value.glob_scan_max_depth, - entries: Some( - value - .entries - .into_iter() - .map(FileSystemSandboxEntry::from) - .collect(), - ), - } - } - } -} - -impl From for CoreFileSystemPermissions { - fn from(value: AdditionalFileSystemPermissions) -> Self { - let mut permissions = if let Some(entries) = value.entries { - Self { - entries: entries - .into_iter() - .map(CoreFileSystemSandboxEntry::from) - .collect(), - glob_scan_max_depth: None, - } - } else { - CoreFileSystemPermissions::from_read_write_roots(value.read, value.write) - }; - permissions.glob_scan_max_depth = value.glob_scan_max_depth; - permissions - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AdditionalNetworkPermissions { - pub enabled: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PermissionProfileNetworkPermissions { - pub enabled: bool, -} - -impl From for AdditionalNetworkPermissions { - fn from(value: CoreNetworkPermissions) -> Self { - Self { - enabled: value.enabled, - } - } -} - -impl From for CoreNetworkPermissions { - fn from(value: AdditionalNetworkPermissions) -> Self { - Self { - enabled: value.enabled, - } - } -} - -impl From for PermissionProfileNetworkPermissions { - fn from(value: CoreNetworkSandboxPolicy) -> Self { - Self { - enabled: value.is_enabled(), - } - } -} - -impl From for CoreNetworkSandboxPolicy { - fn from(value: PermissionProfileNetworkPermissions) -> Self { - if value.enabled { - Self::Enabled - } else { - Self::Restricted - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[serde(deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct RequestPermissionProfile { - pub network: Option, - pub file_system: Option, -} - -impl From for RequestPermissionProfile { - fn from(value: CoreRequestPermissionProfile) -> Self { - Self { - network: value.network.map(AdditionalNetworkPermissions::from), - file_system: value.file_system.map(AdditionalFileSystemPermissions::from), - } - } -} - -impl From for CoreRequestPermissionProfile { - fn from(value: RequestPermissionProfile) -> Self { - Self { - network: value.network.map(CoreNetworkPermissions::from), - file_system: value.file_system.map(CoreFileSystemPermissions::from), - } - } -} - -v2_enum_from_core!( - pub enum FileSystemAccessMode from CoreFileSystemAccessMode { - Read, - Write, - None - } -); - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "kind", rename_all = "snake_case")] -#[ts(tag = "kind")] -#[ts(export_to = "v2/")] -pub enum FileSystemSpecialPath { - Root, - Minimal, - #[serde(alias = "current_working_directory")] - ProjectRoots { - subpath: Option, - }, - Tmpdir, - SlashTmp, - Unknown { - path: String, - subpath: Option, - }, -} - -impl From for FileSystemSpecialPath { - fn from(value: CoreFileSystemSpecialPath) -> Self { - match value { - CoreFileSystemSpecialPath::Root => Self::Root, - CoreFileSystemSpecialPath::Minimal => Self::Minimal, - CoreFileSystemSpecialPath::ProjectRoots { subpath } => Self::ProjectRoots { subpath }, - CoreFileSystemSpecialPath::Tmpdir => Self::Tmpdir, - CoreFileSystemSpecialPath::SlashTmp => Self::SlashTmp, - CoreFileSystemSpecialPath::Unknown { path, subpath } => Self::Unknown { path, subpath }, - } - } -} - -impl From for CoreFileSystemSpecialPath { - fn from(value: FileSystemSpecialPath) -> Self { - match value { - FileSystemSpecialPath::Root => Self::Root, - FileSystemSpecialPath::Minimal => Self::Minimal, - FileSystemSpecialPath::ProjectRoots { subpath } => Self::ProjectRoots { subpath }, - FileSystemSpecialPath::Tmpdir => Self::Tmpdir, - FileSystemSpecialPath::SlashTmp => Self::SlashTmp, - FileSystemSpecialPath::Unknown { path, subpath } => Self::Unknown { path, subpath }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "snake_case")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum FileSystemPath { - Path { path: AbsolutePathBuf }, - GlobPattern { pattern: String }, - Special { value: FileSystemSpecialPath }, -} - -impl From for FileSystemPath { - fn from(value: CoreFileSystemPath) -> Self { - match value { - CoreFileSystemPath::Path { path } => Self::Path { path }, - CoreFileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern }, - CoreFileSystemPath::Special { value } => Self::Special { - value: value.into(), - }, - } - } -} - -impl From for CoreFileSystemPath { - fn from(value: FileSystemPath) -> Self { - match value { - FileSystemPath::Path { path } => Self::Path { path }, - FileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern }, - FileSystemPath::Special { value } => Self::Special { - value: value.into(), - }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FileSystemSandboxEntry { - pub path: FileSystemPath, - pub access: FileSystemAccessMode, -} - -impl From for FileSystemSandboxEntry { - fn from(value: CoreFileSystemSandboxEntry) -> Self { - Self { - path: value.path.into(), - access: value.access.into(), - } - } -} - -impl From for CoreFileSystemSandboxEntry { - fn from(value: FileSystemSandboxEntry) -> Self { - Self { - path: value.path.into(), - access: value.access.to_core(), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileFileSystemPermissions { - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Restricted { - entries: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - glob_scan_max_depth: Option, - }, - Unrestricted, -} - -impl From for PermissionProfileFileSystemPermissions { - fn from(value: CoreManagedFileSystemPermissions) -> Self { - match value { - CoreManagedFileSystemPermissions::Restricted { - entries, - glob_scan_max_depth, - } => Self::Restricted { - entries: entries - .into_iter() - .map(FileSystemSandboxEntry::from) - .collect(), - glob_scan_max_depth, - }, - CoreManagedFileSystemPermissions::Unrestricted => Self::Unrestricted, - } - } -} - -impl From for CoreManagedFileSystemPermissions { - fn from(value: PermissionProfileFileSystemPermissions) -> Self { - match value { - PermissionProfileFileSystemPermissions::Restricted { - entries, - glob_scan_max_depth, - } => Self::Restricted { - entries: entries - .into_iter() - .map(CoreFileSystemSandboxEntry::from) - .collect(), - glob_scan_max_depth, - }, - PermissionProfileFileSystemPermissions::Unrestricted => Self::Unrestricted, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfile { - /// Codex owns sandbox construction for this profile. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Managed { - network: PermissionProfileNetworkPermissions, - file_system: PermissionProfileFileSystemPermissions, - }, - /// Do not apply an outer sandbox. - Disabled, - /// Filesystem isolation is enforced by an external caller. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - External { - network: PermissionProfileNetworkPermissions, - }, -} - -impl From for PermissionProfile { - fn from(value: CorePermissionProfile) -> Self { - match value { - CorePermissionProfile::Managed { - file_system, - network, - } => Self::Managed { - network: network.into(), - file_system: file_system.into(), - }, - CorePermissionProfile::Disabled => Self::Disabled, - CorePermissionProfile::External { network } => Self::External { - network: network.into(), - }, - } - } -} - -impl From for CorePermissionProfile { - fn from(value: PermissionProfile) -> Self { - match value { - PermissionProfile::Managed { - file_system, - network, - } => Self::Managed { - file_system: file_system.into(), - network: network.into(), - }, - PermissionProfile::Disabled => Self::Disabled, - PermissionProfile::External { network } => Self::External { - network: network.into(), - }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ActivePermissionProfile { - /// Identifier from `default_permissions` or the implicit built-in default, - /// such as `:workspace` or a user-defined `[permissions.]` profile. - pub id: String, - /// Parent profile identifier once permissions profiles support - /// inheritance. This is currently always `null`. - #[serde(default)] - pub extends: Option, - /// Bounded user-requested modifications applied on top of the named - /// profile, if any. - #[serde(default)] - pub modifications: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ActivePermissionProfileModification { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AdditionalWritableRoot { path: AbsolutePathBuf }, -} - -impl From for ActivePermissionProfileModification { - fn from(value: CoreActivePermissionProfileModification) -> Self { - match value { - CoreActivePermissionProfileModification::AdditionalWritableRoot { path } => { - Self::AdditionalWritableRoot { path } - } - } - } -} - -impl From for CoreActivePermissionProfileModification { - fn from(value: ActivePermissionProfileModification) -> Self { - match value { - ActivePermissionProfileModification::AdditionalWritableRoot { path } => { - Self::AdditionalWritableRoot { path } - } - } - } -} - -impl From for ActivePermissionProfile { - fn from(value: CoreActivePermissionProfile) -> Self { - Self { - id: value.id, - extends: value.extends, - modifications: value - .modifications - .into_iter() - .map(ActivePermissionProfileModification::from) - .collect(), - } - } -} - -impl From for CoreActivePermissionProfile { - fn from(value: ActivePermissionProfile) -> Self { - Self { - id: value.id, - extends: value.extends, - modifications: value - .modifications - .into_iter() - .map(CoreActivePermissionProfileModification::from) - .collect(), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileSelectionParams { - /// Select a named built-in or user-defined profile and optionally apply - /// bounded modifications that Codex knows how to validate. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Profile { - id: String, - #[ts(optional = nullable)] - modifications: Option>, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileModificationParams { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AdditionalWritableRoot { path: AbsolutePathBuf }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AdditionalPermissionProfile { - /// Partial overlay used for per-command permission requests. - pub network: Option, - pub file_system: Option, -} - -impl From for AdditionalPermissionProfile { - fn from(value: CoreAdditionalPermissionProfile) -> Self { - Self { - network: value.network.map(AdditionalNetworkPermissions::from), - file_system: value.file_system.map(AdditionalFileSystemPermissions::from), - } - } -} - -impl From for CoreAdditionalPermissionProfile { - fn from(value: AdditionalPermissionProfile) -> Self { - Self { - network: value.network.map(CoreNetworkPermissions::from), - file_system: value.file_system.map(CoreFileSystemPermissions::from), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GrantedPermissionProfile { - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub network: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub file_system: Option, -} - -impl From for CoreAdditionalPermissionProfile { - fn from(value: GrantedPermissionProfile) -> Self { - Self { - network: value.network.map(CoreNetworkPermissions::from), - file_system: value.file_system.map(CoreFileSystemPermissions::from), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum FileChangeApprovalDecision { - /// User approved the file changes. - Accept, - /// User approved the file changes and future changes to the same files should run without prompting. - AcceptForSession, - /// User denied the file changes. The agent will continue the turn. - Decline, - /// User denied the file changes. The turn will also be immediately interrupted. - Cancel, -} - -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum NetworkAccess { - #[default] - Restricted, - Enabled, -} - -#[derive(Serialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum SandboxPolicy { - DangerFullAccess, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ReadOnly { - #[serde(default)] - network_access: bool, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ExternalSandbox { - #[serde(default)] - network_access: NetworkAccess, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - WorkspaceWrite { - #[serde(default)] - writable_roots: Vec, - #[serde(default)] - network_access: bool, - #[serde(default)] - exclude_tmpdir_env_var: bool, - #[serde(default)] - exclude_slash_tmp: bool, - }, -} - -#[derive(Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -enum SandboxPolicyDeserialize { - DangerFullAccess, - #[serde(rename_all = "camelCase")] - ReadOnly { - #[serde(default)] - network_access: bool, - #[serde(default)] - access: Option, - }, - #[serde(rename_all = "camelCase")] - ExternalSandbox { - #[serde(default)] - network_access: NetworkAccess, - }, - #[serde(rename_all = "camelCase")] - WorkspaceWrite { - #[serde(default)] - writable_roots: Vec, - #[serde(default)] - read_only_access: Option, - #[serde(default)] - network_access: bool, - #[serde(default)] - exclude_tmpdir_env_var: bool, - #[serde(default)] - exclude_slash_tmp: bool, - }, -} - -#[derive(Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -enum LegacyReadOnlyAccess { - FullAccess, - Restricted, -} - -impl<'de> Deserialize<'de> for SandboxPolicy { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - match SandboxPolicyDeserialize::deserialize(deserializer)? { - SandboxPolicyDeserialize::DangerFullAccess => Ok(SandboxPolicy::DangerFullAccess), - SandboxPolicyDeserialize::ReadOnly { - network_access, - access, - } => { - if matches!(access, Some(LegacyReadOnlyAccess::Restricted)) { - return Err(serde::de::Error::custom( - "readOnly.access is no longer supported; use permissionProfile for restricted reads", - )); - } - Ok(SandboxPolicy::ReadOnly { network_access }) - } - SandboxPolicyDeserialize::ExternalSandbox { network_access } => { - Ok(SandboxPolicy::ExternalSandbox { network_access }) - } - SandboxPolicyDeserialize::WorkspaceWrite { - writable_roots, - read_only_access, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - } => { - if matches!(read_only_access, Some(LegacyReadOnlyAccess::Restricted)) { - return Err(serde::de::Error::custom( - "workspaceWrite.readOnlyAccess is no longer supported; use permissionProfile for restricted reads", - )); - } - Ok(SandboxPolicy::WorkspaceWrite { - writable_roots, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - }) - } - } - } -} - -impl SandboxPolicy { - pub fn to_core(&self) -> codex_protocol::protocol::SandboxPolicy { - match self { - SandboxPolicy::DangerFullAccess => { - codex_protocol::protocol::SandboxPolicy::DangerFullAccess - } - SandboxPolicy::ReadOnly { network_access } => { - codex_protocol::protocol::SandboxPolicy::ReadOnly { - network_access: *network_access, - } - } - SandboxPolicy::ExternalSandbox { network_access } => { - codex_protocol::protocol::SandboxPolicy::ExternalSandbox { - network_access: match network_access { - NetworkAccess::Restricted => CoreNetworkAccess::Restricted, - NetworkAccess::Enabled => CoreNetworkAccess::Enabled, - }, - } - } - SandboxPolicy::WorkspaceWrite { - writable_roots, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - } => codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { - writable_roots: writable_roots.clone(), - network_access: *network_access, - exclude_tmpdir_env_var: *exclude_tmpdir_env_var, - exclude_slash_tmp: *exclude_slash_tmp, - }, - } - } -} - -impl From for SandboxPolicy { - fn from(value: codex_protocol::protocol::SandboxPolicy) -> Self { - match value { - codex_protocol::protocol::SandboxPolicy::DangerFullAccess => { - SandboxPolicy::DangerFullAccess - } - codex_protocol::protocol::SandboxPolicy::ReadOnly { network_access } => { - SandboxPolicy::ReadOnly { network_access } - } - codex_protocol::protocol::SandboxPolicy::ExternalSandbox { network_access } => { - SandboxPolicy::ExternalSandbox { - network_access: match network_access { - CoreNetworkAccess::Restricted => NetworkAccess::Restricted, - CoreNetworkAccess::Enabled => NetworkAccess::Enabled, - }, - } - } - codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { - writable_roots, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - } => SandboxPolicy::WorkspaceWrite { - writable_roots, - network_access, - exclude_tmpdir_env_var, - exclude_slash_tmp, - }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(transparent)] -#[ts(type = "Array", export_to = "v2/")] -pub struct ExecPolicyAmendment { - pub command: Vec, -} - -impl ExecPolicyAmendment { - pub fn into_core(self) -> CoreExecPolicyAmendment { - CoreExecPolicyAmendment::new(self.command) - } -} - -impl From for ExecPolicyAmendment { - fn from(value: CoreExecPolicyAmendment) -> Self { - Self { - command: value.command().to_vec(), - } - } -} - -v2_enum_from_core!( - pub enum NetworkPolicyRuleAction from CoreNetworkPolicyRuleAction { - Allow, Deny - } -); - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct NetworkPolicyAmendment { - pub host: String, - pub action: NetworkPolicyRuleAction, -} - -impl NetworkPolicyAmendment { - pub fn into_core(self) -> CoreNetworkPolicyAmendment { - CoreNetworkPolicyAmendment { - host: self.host, - action: self.action.to_core(), - } - } -} - -impl From for NetworkPolicyAmendment { - fn from(value: CoreNetworkPolicyAmendment) -> Self { - Self { - host: value.host, - action: NetworkPolicyRuleAction::from(value.action), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum CommandAction { - Read { - command: String, - name: String, - path: AbsolutePathBuf, - }, - ListFiles { - command: String, - path: Option, - }, - Search { - command: String, - query: Option, - path: Option, - }, - Unknown { - command: String, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase", export_to = "v2/")] -#[derive(Default)] -pub enum SessionSource { - Cli, - #[serde(rename = "vscode")] - #[ts(rename = "vscode")] - #[default] - VsCode, - Exec, - AppServer, - Custom(String), - SubAgent(CoreSubAgentSource), - #[serde(other)] - Unknown, -} - -impl From for SessionSource { - fn from(value: CoreSessionSource) -> Self { - match value { - CoreSessionSource::Cli => SessionSource::Cli, - CoreSessionSource::VSCode => SessionSource::VsCode, - CoreSessionSource::Exec => SessionSource::Exec, - CoreSessionSource::Mcp => SessionSource::AppServer, - CoreSessionSource::Custom(source) => SessionSource::Custom(source), - // We do not want to render those at the app-server level. - CoreSessionSource::Internal(_) => SessionSource::Unknown, - CoreSessionSource::SubAgent(sub) => SessionSource::SubAgent(sub), - CoreSessionSource::Unknown => SessionSource::Unknown, - } - } -} - -impl From for CoreSessionSource { - fn from(value: SessionSource) -> Self { - match value { - SessionSource::Cli => CoreSessionSource::Cli, - SessionSource::VsCode => CoreSessionSource::VSCode, - SessionSource::Exec => CoreSessionSource::Exec, - SessionSource::AppServer => CoreSessionSource::Mcp, - SessionSource::Custom(source) => CoreSessionSource::Custom(source), - SessionSource::SubAgent(sub) => CoreSessionSource::SubAgent(sub), - SessionSource::Unknown => CoreSessionSource::Unknown, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GitInfo { - pub sha: Option, - pub branch: Option, - pub origin_url: Option, -} - -impl CommandAction { - pub fn into_core(self) -> CoreParsedCommand { - match self { - CommandAction::Read { - command: cmd, - name, - path, - } => CoreParsedCommand::Read { - cmd, - name, - path: path.into_path_buf(), - }, - CommandAction::ListFiles { command: cmd, path } => { - CoreParsedCommand::ListFiles { cmd, path } - } - CommandAction::Search { - command: cmd, - query, - path, - } => CoreParsedCommand::Search { cmd, query, path }, - CommandAction::Unknown { command: cmd } => CoreParsedCommand::Unknown { cmd }, - } - } -} - -impl CommandAction { - pub fn from_core_with_cwd(value: CoreParsedCommand, cwd: &AbsolutePathBuf) -> Self { - match value { - CoreParsedCommand::Read { cmd, name, path } => CommandAction::Read { - command: cmd, - name, - path: cwd.join(path), - }, - CoreParsedCommand::ListFiles { cmd, path } => { - CommandAction::ListFiles { command: cmd, path } - } - CoreParsedCommand::Search { cmd, query, path } => CommandAction::Search { - command: cmd, - query, - path, - }, - CoreParsedCommand::Unknown { cmd } => CommandAction::Unknown { command: cmd }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum Account { - #[serde(rename = "apiKey", rename_all = "camelCase")] - #[ts(rename = "apiKey", rename_all = "camelCase")] - ApiKey {}, - - #[serde(rename = "chatgpt", rename_all = "camelCase")] - #[ts(rename = "chatgpt", rename_all = "camelCase")] - Chatgpt { email: String, plan_type: PlanType }, - - #[serde(rename = "amazonBedrock", rename_all = "camelCase")] - #[ts(rename = "amazonBedrock", rename_all = "camelCase")] - AmazonBedrock {}, -} - -impl From for Account { - fn from(account: ProviderAccount) -> Self { - match account { - ProviderAccount::ApiKey => Self::ApiKey {}, - ProviderAccount::Chatgpt { email, plan_type } => Self::Chatgpt { email, plan_type }, - ProviderAccount::AmazonBedrock => Self::AmazonBedrock {}, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(tag = "type")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum LoginAccountParams { - #[serde(rename = "apiKey", rename_all = "camelCase")] - #[ts(rename = "apiKey", rename_all = "camelCase")] - ApiKey { - #[serde(rename = "apiKey")] - #[ts(rename = "apiKey")] - api_key: String, - }, - #[serde(rename = "chatgpt", rename_all = "camelCase")] - #[ts(rename = "chatgpt", rename_all = "camelCase")] - Chatgpt { - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - codex_streamlined_login: bool, - }, - #[serde(rename = "chatgptDeviceCode")] - #[ts(rename = "chatgptDeviceCode")] - ChatgptDeviceCode, - /// [UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. - /// The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have. - #[experimental("account/login/start.chatgptAuthTokens")] - #[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")] - #[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")] - ChatgptAuthTokens { - /// Access token (JWT) supplied by the client. - /// This token is used for backend API requests and email extraction. - access_token: String, - /// Workspace/account identifier supplied by the client. - chatgpt_account_id: String, - /// Optional plan type supplied by the client. - /// - /// When `null`, Codex attempts to derive the plan type from access-token - /// claims. If unavailable, the plan defaults to `unknown`. - #[ts(optional = nullable)] - chatgpt_plan_type: Option, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum LoginAccountResponse { - #[serde(rename = "apiKey", rename_all = "camelCase")] - #[ts(rename = "apiKey", rename_all = "camelCase")] - ApiKey {}, - #[serde(rename = "chatgpt", rename_all = "camelCase")] - #[ts(rename = "chatgpt", rename_all = "camelCase")] - Chatgpt { - // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. - // Convert to/from UUIDs at the application layer as needed. - login_id: String, - /// URL the client should open in a browser to initiate the OAuth flow. - auth_url: String, - }, - #[serde(rename = "chatgptDeviceCode", rename_all = "camelCase")] - #[ts(rename = "chatgptDeviceCode", rename_all = "camelCase")] - ChatgptDeviceCode { - // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. - // Convert to/from UUIDs at the application layer as needed. - login_id: String, - /// URL the client should open in a browser to complete device code authorization. - verification_url: String, - /// One-time code the user must enter after signing in. - user_code: String, - }, - #[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")] - #[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")] - ChatgptAuthTokens {}, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CancelLoginAccountParams { - pub login_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CancelLoginAccountStatus { - Canceled, - NotFound, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CancelLoginAccountResponse { - pub status: CancelLoginAccountStatus, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct LogoutAccountResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ChatgptAuthTokensRefreshReason { - /// Codex attempted a backend request and received `401 Unauthorized`. - Unauthorized, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ChatgptAuthTokensRefreshParams { - pub reason: ChatgptAuthTokensRefreshReason, - /// Workspace/account identifier that Codex was previously using. - /// - /// Clients that manage multiple accounts/workspaces can use this as a hint - /// to refresh the token for the correct workspace. - /// - /// This may be `null` when the prior auth state did not include a workspace - /// identifier (`chatgpt_account_id`). - #[ts(optional = nullable)] - pub previous_account_id: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ChatgptAuthTokensRefreshResponse { - pub access_token: String, - pub chatgpt_account_id: String, - pub chatgpt_plan_type: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GetAccountRateLimitsResponse { - /// Backward-compatible single-bucket view; mirrors the historical payload. - pub rate_limits: RateLimitSnapshot, - /// Multi-bucket view keyed by metered `limit_id` (for example, `codex`). - pub rate_limits_by_limit_id: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SendAddCreditsNudgeEmailParams { - pub credit_type: AddCreditsNudgeCreditType, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/", rename_all = "snake_case")] -pub enum AddCreditsNudgeCreditType { - Credits, - UsageLimit, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SendAddCreditsNudgeEmailResponse { - pub status: AddCreditsNudgeEmailStatus, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/", rename_all = "snake_case")] -pub enum AddCreditsNudgeEmailStatus { - Sent, - CooldownActive, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GetAccountParams { - /// When `true`, requests a proactive token refresh before returning. - /// - /// In managed auth mode this triggers the normal refresh-token flow. In - /// external auth mode this flag is ignored. Clients should refresh tokens - /// themselves and call `account/login/start` with `chatgptAuthTokens`. - #[serde(default)] - pub refresh_token: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GetAccountResponse { - pub account: Option, - pub requires_openai_auth: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelProviderCapabilitiesReadParams {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelProviderCapabilitiesReadResponse { - pub namespace_tools: bool, - pub image_generation: bool, - pub web_search: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelListParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to a reasonable server-side value. - #[ts(optional = nullable)] - pub limit: Option, - /// When true, include models that are hidden from the default picker list. - #[ts(optional = nullable)] - pub include_hidden: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelAvailabilityNux { - pub message: String, -} - -impl From for ModelAvailabilityNux { - fn from(value: CoreModelAvailabilityNux) -> Self { - Self { - message: value.message, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct Model { - pub id: String, - pub model: String, - pub upgrade: Option, - pub upgrade_info: Option, - pub availability_nux: Option, - pub display_name: String, - pub description: String, - pub hidden: bool, - pub supported_reasoning_efforts: Vec, - pub default_reasoning_effort: ReasoningEffort, - #[serde(default = "default_input_modalities")] - pub input_modalities: Vec, - #[serde(default)] - pub supports_personality: bool, - #[serde(default)] - pub additional_speed_tiers: Vec, - // Only one model should be marked as default. - pub is_default: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelUpgradeInfo { - pub model: String, - pub upgrade_copy: Option, - pub model_link: Option, - pub migration_markdown: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReasoningEffortOption { - pub reasoning_effort: ReasoningEffort, - pub description: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelListResponse { - pub data: Vec, - /// Opaque cursor to pass to the next call to continue after the last item. - /// If None, there are no more items to return. - pub next_cursor: Option, -} - -/// EXPERIMENTAL - list collaboration mode presets. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CollaborationModeListParams {} - -/// EXPERIMENTAL - collaboration mode preset metadata for clients. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CollaborationModeMask { - pub name: String, - pub mode: Option, - pub model: Option, - #[serde(rename = "reasoning_effort")] - #[ts(rename = "reasoning_effort")] - pub reasoning_effort: Option>, -} - -impl From for CollaborationModeMask { - fn from(value: CoreCollaborationModeMask) -> Self { - Self { - name: value.name, - mode: value.mode, - model: value.model, - reasoning_effort: value.reasoning_effort, - } - } -} - -/// EXPERIMENTAL - collaboration mode presets response. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CollaborationModeListResponse { - pub data: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExperimentalFeatureListParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to a reasonable server-side value. - #[ts(optional = nullable)] - pub limit: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ExperimentalFeatureStage { - /// Feature is available for user testing and feedback. - Beta, - /// Feature is still being built and not ready for broad use. - UnderDevelopment, - /// Feature is production-ready. - Stable, - /// Feature is deprecated and should be avoided. - Deprecated, - /// Feature flag is retained only for backwards compatibility. - Removed, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExperimentalFeature { - /// Stable key used in config.toml and CLI flag toggles. - pub name: String, - /// Lifecycle stage of this feature flag. - pub stage: ExperimentalFeatureStage, - /// User-facing display name shown in the experimental features UI. - /// Null when this feature is not in beta. - pub display_name: Option, - /// Short summary describing what the feature does. - /// Null when this feature is not in beta. - pub description: Option, - /// Announcement copy shown to users when the feature is introduced. - /// Null when this feature is not in beta. - pub announcement: Option, - /// Whether this feature is currently enabled in the loaded config. - pub enabled: bool, - /// Whether this feature is enabled by default. - pub default_enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExperimentalFeatureListResponse { - pub data: Vec, - /// Opaque cursor to pass to the next call to continue after the last item. - /// If None, there are no more items to return. - pub next_cursor: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExperimentalFeatureEnablementSetParams { - /// Process-wide runtime feature enablement keyed by canonical feature name. - /// - /// Only named features are updated. Omitted features are left unchanged. - /// Send an empty map for a no-op. - pub enablement: std::collections::BTreeMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ExperimentalFeatureEnablementSetResponse { - /// Feature enablement entries updated by this request. - pub enablement: std::collections::BTreeMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ListMcpServerStatusParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to a server-defined value. - #[ts(optional = nullable)] - pub limit: Option, - /// Controls how much MCP inventory data to fetch for each server. - /// Defaults to `Full` when omitted. - #[ts(optional = nullable)] - pub detail: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase", export_to = "v2/")] -pub enum McpServerStatusDetail { - Full, - ToolsAndAuthOnly, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerStatus { - pub name: String, - pub tools: std::collections::HashMap, - pub resources: Vec, - pub resource_templates: Vec, - pub auth_status: McpAuthStatus, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ListMcpServerStatusResponse { - pub data: Vec, - /// Opaque cursor to pass to the next call to continue after the last item. - /// If None, there are no more items to return. - pub next_cursor: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpResourceReadParams { - #[ts(optional = nullable)] - pub thread_id: Option, - pub server: String, - pub uri: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpResourceReadResponse { - pub contents: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerToolCallParams { - pub thread_id: String, - pub server: String, - pub tool: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub arguments: Option, - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub meta: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerToolCallResponse { - pub content: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub structured_content: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub is_error: Option, - #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub meta: Option, -} - -impl From for McpServerToolCallResponse { - fn from(result: CoreMcpCallToolResult) -> Self { - Self { - content: result.content, - structured_content: result.structured_content, - is_error: result.is_error, - meta: result.meta, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - list available apps/connectors. -pub struct AppsListParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to a reasonable server-side value. - #[ts(optional = nullable)] - pub limit: Option, - /// Optional thread id used to evaluate app feature gating from that thread's config. - #[ts(optional = nullable)] - pub thread_id: Option, - /// When true, bypass app caches and fetch the latest data from sources. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub force_refetch: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - app metadata returned by app-list APIs. -pub struct AppBranding { - pub category: Option, - pub developer: Option, - pub website: Option, - pub privacy_policy: Option, - pub terms_of_service: Option, - pub is_discoverable_app: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AppReview { - pub status: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AppScreenshot { - pub url: Option, - #[serde(alias = "file_id")] - pub file_id: Option, - #[serde(alias = "user_prompt")] - pub user_prompt: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AppMetadata { - pub review: Option, - pub categories: Option>, - pub sub_categories: Option>, - pub seo_description: Option, - pub screenshots: Option>, - pub developer: Option, - pub version: Option, - pub version_id: Option, - pub version_notes: Option, - pub first_party_type: Option, - pub first_party_requires_install: Option, - pub show_in_composer_when_unlinked: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - app metadata returned by app-list APIs. -pub struct AppInfo { - pub id: String, - pub name: String, - pub description: Option, - pub logo_url: Option, - pub logo_url_dark: Option, - pub distribution_channel: Option, - pub branding: Option, - pub app_metadata: Option, - pub labels: Option>, - pub install_url: Option, - #[serde(default)] - pub is_accessible: bool, - /// Whether this app is enabled in config.toml. - /// Example: - /// ```toml - /// [apps.bad_app] - /// enabled = false - /// ``` - #[serde(default = "default_enabled")] - pub is_enabled: bool, - #[serde(default)] - pub plugin_display_names: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - app metadata summary for plugin responses. -pub struct AppSummary { - pub id: String, - pub name: String, - pub description: Option, - pub install_url: Option, - pub needs_auth: bool, -} - -impl From for AppSummary { - fn from(value: AppInfo) -> Self { - Self { - id: value.id, - name: value.name, - description: value.description, - install_url: value.install_url, - needs_auth: false, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - app list response. -pub struct AppsListResponse { - pub data: Vec, - /// Opaque cursor to pass to the next call to continue after the last item. - /// If None, there are no more items to return. - pub next_cursor: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - notification emitted when the app list changes. -pub struct AppListUpdatedNotification { - pub data: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerRefreshParams {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerRefreshResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerOauthLoginParams { - pub name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub scopes: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub timeout_secs: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerOauthLoginResponse { - pub authorization_url: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FeedbackUploadParams { - pub classification: String, - #[ts(optional = nullable)] - pub reason: Option, - #[ts(optional = nullable)] - pub thread_id: Option, - pub include_logs: bool, - #[ts(optional = nullable)] - pub extra_log_files: Option>, - #[ts(optional = nullable)] - pub tags: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FeedbackUploadResponse { - pub thread_id: String, -} - -/// Device-key algorithm reported at enrollment and signing boundaries. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case", export_to = "v2/")] -pub enum DeviceKeyAlgorithm { - EcdsaP256Sha256, -} - -/// Platform protection class for a controller-local device key. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case", export_to = "v2/")] -pub enum DeviceKeyProtectionClass { - HardwareSecureEnclave, - HardwareTpm, - OsProtectedNonextractable, -} - -/// Protection policy for creating or loading a controller-local device key. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case", export_to = "v2/")] -pub enum DeviceKeyProtectionPolicy { - HardwareOnly, - AllowOsProtectedNonextractable, -} - -/// Create a controller-local device key with a random key id. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DeviceKeyCreateParams { - /// Defaults to `hardware_only` when omitted. - #[ts(optional = nullable)] - pub protection_policy: Option, - pub account_user_id: String, - pub client_id: String, -} - -/// Device-key metadata and public key returned by create/public APIs. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DeviceKeyCreateResponse { - pub key_id: String, - /// SubjectPublicKeyInfo DER encoded as base64. - pub public_key_spki_der_base64: String, - pub algorithm: DeviceKeyAlgorithm, - pub protection_class: DeviceKeyProtectionClass, -} - -/// Fetch a controller-local device key public key by id. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DeviceKeyPublicParams { - pub key_id: String, -} - -/// Device-key public metadata returned by `device/key/public`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DeviceKeyPublicResponse { - pub key_id: String, - /// SubjectPublicKeyInfo DER encoded as base64. - pub public_key_spki_der_base64: String, - pub algorithm: DeviceKeyAlgorithm, - pub protection_class: DeviceKeyProtectionClass, -} - -/// Current remote-control connection status and environment id exposed to clients. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct RemoteControlStatusChangedNotification { - pub status: RemoteControlConnectionStatus, - pub environment_id: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase", export_to = "v2/")] -pub enum RemoteControlConnectionStatus { - Disabled, - Connecting, - Connected, - Errored, -} - -/// Audience for a remote-control client connection device-key proof. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case", export_to = "v2/")] -pub enum RemoteControlClientConnectionAudience { - RemoteControlClientWebsocket, -} - -/// Audience for a remote-control client enrollment device-key proof. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case", export_to = "v2/")] -pub enum RemoteControlClientEnrollmentAudience { - RemoteControlClientEnrollment, -} - -/// Structured payloads accepted by `device/key/sign`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type", export_to = "v2/")] -pub enum DeviceKeySignPayload { - /// Payload bound to one remote-control controller websocket `/client` connection challenge. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - RemoteControlClientConnection { - nonce: String, - audience: RemoteControlClientConnectionAudience, - /// Backend-issued websocket session id that this proof authorizes. - session_id: String, - /// Origin of the backend endpoint that issued the challenge and will verify this proof. - target_origin: String, - /// Websocket route path that this proof authorizes. - target_path: String, - account_user_id: String, - client_id: String, - /// Remote-control token expiration as Unix seconds. - #[ts(type = "number")] - token_expires_at: i64, - /// SHA-256 of the controller-scoped remote-control token, encoded as unpadded base64url. - token_sha256_base64url: String, - /// Must contain exactly `remote_control_controller_websocket`. - scopes: Vec, - }, - /// Payload bound to a remote-control client `/client/enroll` ownership challenge. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - RemoteControlClientEnrollment { - nonce: String, - audience: RemoteControlClientEnrollmentAudience, - /// Backend-issued enrollment challenge id that this proof authorizes. - challenge_id: String, - /// Origin of the backend endpoint that issued the challenge and will verify this proof. - target_origin: String, - /// HTTP route path that this proof authorizes. - target_path: String, - account_user_id: String, - client_id: String, - /// SHA-256 of the requested device identity operation, encoded as unpadded base64url. - device_identity_sha256_base64url: String, - /// Enrollment challenge expiration as Unix seconds. - #[ts(type = "number")] - challenge_expires_at: i64, - }, -} - -/// Sign an accepted structured payload with a controller-local device key. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DeviceKeySignParams { - pub key_id: String, - pub payload: DeviceKeySignPayload, -} - -/// ASN.1 DER signature returned by `device/key/sign`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DeviceKeySignResponse { - /// ECDSA signature DER encoded as base64. - pub signature_der_base64: String, - /// Exact bytes signed by the device key, encoded as base64. Verifiers must verify this byte - /// string directly and must not reserialize `payload`. - pub signed_payload_base64: String, - pub algorithm: DeviceKeyAlgorithm, -} - -/// Read a file from the host filesystem. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsReadFileParams { - /// Absolute path to read. - pub path: AbsolutePathBuf, -} - -/// Base64-encoded file contents returned by `fs/readFile`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsReadFileResponse { - /// File contents encoded as base64. - pub data_base64: String, -} - -/// Write a file on the host filesystem. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsWriteFileParams { - /// Absolute path to write. - pub path: AbsolutePathBuf, - /// File contents encoded as base64. - pub data_base64: String, -} - -/// Successful response for `fs/writeFile`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsWriteFileResponse {} - -/// Create a directory on the host filesystem. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsCreateDirectoryParams { - /// Absolute directory path to create. - pub path: AbsolutePathBuf, - /// Whether parent directories should also be created. Defaults to `true`. - #[ts(optional = nullable)] - pub recursive: Option, -} - -/// Successful response for `fs/createDirectory`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsCreateDirectoryResponse {} - -/// Request metadata for an absolute path. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsGetMetadataParams { - /// Absolute path to inspect. - pub path: AbsolutePathBuf, -} - -/// Metadata returned by `fs/getMetadata`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsGetMetadataResponse { - /// Whether the path resolves to a directory. - pub is_directory: bool, - /// Whether the path resolves to a regular file. - pub is_file: bool, - /// Whether the path itself is a symbolic link. - pub is_symlink: bool, - /// File creation time in Unix milliseconds when available, otherwise `0`. - #[ts(type = "number")] - pub created_at_ms: i64, - /// File modification time in Unix milliseconds when available, otherwise `0`. - #[ts(type = "number")] - pub modified_at_ms: i64, -} - -/// List direct child names for a directory. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsReadDirectoryParams { - /// Absolute directory path to read. - pub path: AbsolutePathBuf, -} - -/// A directory entry returned by `fs/readDirectory`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsReadDirectoryEntry { - /// Direct child entry name only, not an absolute or relative path. - pub file_name: String, - /// Whether this entry resolves to a directory. - pub is_directory: bool, - /// Whether this entry resolves to a regular file. - pub is_file: bool, -} - -/// Directory entries returned by `fs/readDirectory`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsReadDirectoryResponse { - /// Direct child entries in the requested directory. - pub entries: Vec, -} - -/// Remove a file or directory tree from the host filesystem. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsRemoveParams { - /// Absolute path to remove. - pub path: AbsolutePathBuf, - /// Whether directory removal should recurse. Defaults to `true`. - #[ts(optional = nullable)] - pub recursive: Option, - /// Whether missing paths should be ignored. Defaults to `true`. - #[ts(optional = nullable)] - pub force: Option, -} - -/// Successful response for `fs/remove`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsRemoveResponse {} - -/// Copy a file or directory tree on the host filesystem. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsCopyParams { - /// Absolute source path. - pub source_path: AbsolutePathBuf, - /// Absolute destination path. - pub destination_path: AbsolutePathBuf, - /// Required for directory copies; ignored for file copies. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub recursive: bool, -} - -/// Successful response for `fs/copy`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsCopyResponse {} - -/// Start filesystem watch notifications for an absolute path. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsWatchParams { - /// Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`. - pub watch_id: String, - /// Absolute file or directory path to watch. - pub path: AbsolutePathBuf, -} - -/// Successful response for `fs/watch`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsWatchResponse { - /// Canonicalized path associated with the watch. - pub path: AbsolutePathBuf, -} - -/// Stop filesystem watch notifications for a prior `fs/watch`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsUnwatchParams { - /// Watch identifier previously provided to `fs/watch`. - pub watch_id: String, -} - -/// Successful response for `fs/unwatch`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsUnwatchResponse {} - -/// Filesystem watch notification emitted for `fs/watch` subscribers. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FsChangedNotification { - /// Watch identifier previously provided to `fs/watch`. - pub watch_id: String, - /// File or directory paths associated with this event. - pub changed_paths: Vec, -} - -/// PTY size in character cells for `command/exec` PTY sessions. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecTerminalSize { - /// Terminal height in character cells. - pub rows: u16, - /// Terminal width in character cells. - pub cols: u16, -} - -/// Run a standalone command (argv vector) in the server sandbox without -/// creating a thread or turn. -/// -/// The final `command/exec` response is deferred until the process exits and is -/// sent only after all `command/exec/outputDelta` notifications for that -/// connection have been emitted. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecParams { - /// Command argv vector. Empty arrays are rejected. - pub command: Vec, - /// Optional client-supplied, connection-scoped process id. - /// - /// Required for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up - /// `command/exec/write`, `command/exec/resize`, and - /// `command/exec/terminate` calls. When omitted, buffered execution gets an - /// internal id that is not exposed to the client. - #[ts(optional = nullable)] - pub process_id: Option, - /// Enable PTY mode. - /// - /// This implies `streamStdin` and `streamStdoutStderr`. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub tty: bool, - /// Allow follow-up `command/exec/write` requests to write stdin bytes. - /// - /// Requires a client-supplied `processId`. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub stream_stdin: bool, - /// Stream stdout/stderr via `command/exec/outputDelta` notifications. - /// - /// Streamed bytes are not duplicated into the final response and require a - /// client-supplied `processId`. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub stream_stdout_stderr: bool, - /// Optional per-stream stdout/stderr capture cap in bytes. - /// - /// When omitted, the server default applies. Cannot be combined with - /// `disableOutputCap`. - #[ts(type = "number | null")] - #[ts(optional = nullable)] - pub output_bytes_cap: Option, - /// Disable stdout/stderr capture truncation for this request. - /// - /// Cannot be combined with `outputBytesCap`. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub disable_output_cap: bool, - /// Disable the timeout entirely for this request. - /// - /// Cannot be combined with `timeoutMs`. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub disable_timeout: bool, - /// Optional timeout in milliseconds. - /// - /// When omitted, the server default applies. Cannot be combined with - /// `disableTimeout`. - #[ts(type = "number | null")] - #[ts(optional = nullable)] - pub timeout_ms: Option, - /// Optional working directory. Defaults to the server cwd. - #[ts(optional = nullable)] - pub cwd: Option, - /// Optional environment overrides merged into the server-computed - /// environment. - /// - /// Matching names override inherited values. Set a key to `null` to unset - /// an inherited variable. - #[ts(optional = nullable)] - pub env: Option>>, - /// Optional initial PTY size in character cells. Only valid when `tty` is - /// true. - #[ts(optional = nullable)] - pub size: Option, - /// Optional sandbox policy for this command. - /// - /// Uses the same shape as thread/turn execution sandbox configuration and - /// defaults to the user's configured policy when omitted. Cannot be - /// combined with `permissionProfile`. - #[ts(optional = nullable)] - pub sandbox_policy: Option, - /// Optional full permissions profile for this command. - /// - /// Defaults to the user's configured permissions when omitted. Cannot be - /// combined with `sandboxPolicy`. - #[experimental("command/exec.permissionProfile")] - #[ts(optional = nullable)] - pub permission_profile: Option, -} - -/// Final buffered result for `command/exec`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecResponse { - /// Process exit code. - pub exit_code: i32, - /// Buffered stdout capture. - /// - /// Empty when stdout was streamed via `command/exec/outputDelta`. - pub stdout: String, - /// Buffered stderr capture. - /// - /// Empty when stderr was streamed via `command/exec/outputDelta`. - pub stderr: String, -} - -/// Write stdin bytes to a running `command/exec` session, close stdin, or -/// both. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecWriteParams { - /// Client-supplied, connection-scoped `processId` from the original - /// `command/exec` request. - pub process_id: String, - /// Optional base64-encoded stdin bytes to write. - #[ts(optional = nullable)] - pub delta_base64: Option, - /// Close stdin after writing `deltaBase64`, if present. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub close_stdin: bool, -} - -/// Empty success response for `command/exec/write`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecWriteResponse {} - -/// Terminate a running `command/exec` session. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecTerminateParams { - /// Client-supplied, connection-scoped `processId` from the original - /// `command/exec` request. - pub process_id: String, -} - -/// Empty success response for `command/exec/terminate`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecTerminateResponse {} - -/// Resize a running PTY-backed `command/exec` session. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecResizeParams { - /// Client-supplied, connection-scoped `processId` from the original - /// `command/exec` request. - pub process_id: String, - /// New PTY size in character cells. - pub size: CommandExecTerminalSize, -} - -/// Empty success response for `command/exec/resize`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecResizeResponse {} - -/// Stream label for `command/exec/outputDelta` notifications. -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CommandExecOutputStream { - /// stdout stream. PTY mode multiplexes terminal output here. - Stdout, - /// stderr stream. - Stderr, -} - -// === Threads, Turns, and Items === -// Thread APIs -#[derive( - Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS, ExperimentalApi, -)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadStartParams { - #[ts(optional = nullable)] - pub model: Option, - #[ts(optional = nullable)] - pub model_provider: Option, - #[serde( - default, - deserialize_with = "super::serde_helpers::deserialize_double_option", - serialize_with = "super::serde_helpers::serialize_double_option", - skip_serializing_if = "Option::is_none" - )] - #[ts(optional = nullable)] - pub service_tier: Option>, - #[ts(optional = nullable)] - pub cwd: Option, - #[experimental(nested)] - #[ts(optional = nullable)] - pub approval_policy: Option, - /// Override where approval requests are routed for review on this thread - /// and subsequent turns. - #[ts(optional = nullable)] - pub approvals_reviewer: Option, - #[ts(optional = nullable)] - pub sandbox: Option, - /// Named profile selection for this thread. Cannot be combined with - /// `sandbox`. Use bounded `modifications` for supported turn/thread - /// adjustments instead of replacing the full permissions profile. - #[experimental("thread/start.permissions")] - #[ts(optional = nullable)] - pub permissions: Option, - #[ts(optional = nullable)] - pub config: Option>, - #[ts(optional = nullable)] - pub service_name: Option, - #[ts(optional = nullable)] - pub base_instructions: Option, - #[ts(optional = nullable)] - pub developer_instructions: Option, - #[ts(optional = nullable)] - pub personality: Option, - #[ts(optional = nullable)] - pub ephemeral: Option, - #[ts(optional = nullable)] - pub session_start_source: Option, - /// Optional sticky environments for this thread. - /// - /// Omitted selects the default environment when environment access is - /// enabled. Empty disables environment access for turns that do not - /// provide a turn override. Non-empty selects the first environment as the - /// current turn environment. - #[experimental("thread/start.environments")] - #[ts(optional = nullable)] - pub environments: Option>, - #[experimental("thread/start.dynamicTools")] - #[ts(optional = nullable)] - pub dynamic_tools: Option>, - /// Test-only experimental field used to validate experimental gating and - /// schema filtering behavior in a stable way. - #[experimental("thread/start.mockExperimentalField")] - #[ts(optional = nullable)] - pub mock_experimental_field: Option, - /// If true, opt into emitting raw Responses API items on the event stream. - /// This is for internal use only (e.g. Codex Cloud). - #[experimental("thread/start.experimentalRawEvents")] - #[serde(default)] - pub experimental_raw_events: bool, - /// If true, persist additional rollout EventMsg variants required to - /// reconstruct a richer thread history on resume/fork/read. - #[experimental("thread/start.persistFullHistory")] - #[serde(default)] - pub persist_extended_history: bool, -} - -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MockExperimentalMethodParams { - /// Test-only payload field. - #[ts(optional = nullable)] - pub value: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MockExperimentalMethodResponse { - /// Echoes the input `value`. - pub echoed: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadStartResponse { - pub thread: Thread, - pub model: String, - pub model_provider: String, - pub service_tier: Option, - pub cwd: AbsolutePathBuf, - /// Instruction source files currently loaded for this thread. - #[serde(default)] - pub instruction_sources: Vec, - #[experimental(nested)] - pub approval_policy: AskForApproval, - /// Reviewer currently used for approval requests on this thread. - pub approvals_reviewer: ApprovalsReviewer, - /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. - pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. - #[experimental("thread/start.permissionProfile")] - #[serde(default)] - pub permission_profile: Option, - /// Named or implicit built-in profile that produced the active - /// permissions, when known. - #[experimental("thread/start.activePermissionProfile")] - #[serde(default)] - pub active_permission_profile: Option, - pub reasoning_effort: Option, -} - -#[derive( - Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, -)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// There are three ways to resume a thread: -/// 1. By thread_id: load the thread from disk by thread_id and resume it. -/// 2. By history: instantiate the thread from memory and resume it. -/// 3. By path: load the thread from disk by path and resume it. -/// -/// The precedence is: history > path > thread_id. -/// If using history or path, the thread_id param will be ignored. -/// -/// Prefer using thread_id whenever possible. -pub struct ThreadResumeParams { - pub thread_id: String, - - /// [UNSTABLE] FOR CODEX CLOUD - DO NOT USE. - /// If specified, the thread will be resumed with the provided history - /// instead of loaded from disk. - #[experimental("thread/resume.history")] - #[ts(optional = nullable)] - pub history: Option>, - - /// [UNSTABLE] Specify the rollout path to resume from. - /// If specified, the thread_id param will be ignored. - #[experimental("thread/resume.path")] - #[ts(optional = nullable)] - pub path: Option, - - /// Configuration overrides for the resumed thread, if any. - #[ts(optional = nullable)] - pub model: Option, - #[ts(optional = nullable)] - pub model_provider: Option, - #[serde( - default, - deserialize_with = "super::serde_helpers::deserialize_double_option", - serialize_with = "super::serde_helpers::serialize_double_option", - skip_serializing_if = "Option::is_none" - )] - #[ts(optional = nullable)] - pub service_tier: Option>, - #[ts(optional = nullable)] - pub cwd: Option, - #[experimental(nested)] - #[ts(optional = nullable)] - pub approval_policy: Option, - /// Override where approval requests are routed for review on this thread - /// and subsequent turns. - #[ts(optional = nullable)] - pub approvals_reviewer: Option, - #[ts(optional = nullable)] - pub sandbox: Option, - /// Named profile selection for the resumed thread. Cannot be combined - /// with `sandbox`. Use bounded `modifications` for supported thread - /// adjustments instead of replacing the full permissions profile. - #[experimental("thread/resume.permissions")] - #[ts(optional = nullable)] - pub permissions: Option, - #[ts(optional = nullable)] - pub config: Option>, - #[ts(optional = nullable)] - pub base_instructions: Option, - #[ts(optional = nullable)] - pub developer_instructions: Option, - #[ts(optional = nullable)] - pub personality: Option, - /// When true, return only thread metadata and live-resume state without - /// populating `thread.turns`. This is useful when the client plans to call - /// `thread/turns/list` immediately after resuming. - #[experimental("thread/resume.excludeTurns")] - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub exclude_turns: bool, - /// If true, persist additional rollout EventMsg variants required to - /// reconstruct a richer thread history on subsequent resume/fork/read. - #[experimental("thread/resume.persistFullHistory")] - #[serde(default)] - pub persist_extended_history: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadResumeResponse { - pub thread: Thread, - pub model: String, - pub model_provider: String, - pub service_tier: Option, - pub cwd: AbsolutePathBuf, - /// Instruction source files currently loaded for this thread. - #[serde(default)] - pub instruction_sources: Vec, - #[experimental(nested)] - pub approval_policy: AskForApproval, - /// Reviewer currently used for approval requests on this thread. - pub approvals_reviewer: ApprovalsReviewer, - /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. - pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. - #[experimental("thread/resume.permissionProfile")] - #[serde(default)] - pub permission_profile: Option, - /// Named or implicit built-in profile that produced the active - /// permissions, when known. - #[experimental("thread/resume.activePermissionProfile")] - #[serde(default)] - pub active_permission_profile: Option, - pub reasoning_effort: Option, -} - -#[derive( - Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, -)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// There are two ways to fork a thread: -/// 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. -/// 2. By path: load the thread from disk by path and fork it into a new thread. -/// -/// If using path, the thread_id param will be ignored. -/// -/// Prefer using thread_id whenever possible. -pub struct ThreadForkParams { - pub thread_id: String, - - /// [UNSTABLE] Specify the rollout path to fork from. - /// If specified, the thread_id param will be ignored. - #[experimental("thread/fork.path")] - #[ts(optional = nullable)] - pub path: Option, - - /// Configuration overrides for the forked thread, if any. - #[ts(optional = nullable)] - pub model: Option, - #[ts(optional = nullable)] - pub model_provider: Option, - #[serde( - default, - deserialize_with = "super::serde_helpers::deserialize_double_option", - serialize_with = "super::serde_helpers::serialize_double_option", - skip_serializing_if = "Option::is_none" - )] - #[ts(optional = nullable)] - pub service_tier: Option>, - #[ts(optional = nullable)] - pub cwd: Option, - #[experimental(nested)] - #[ts(optional = nullable)] - pub approval_policy: Option, - /// Override where approval requests are routed for review on this thread - /// and subsequent turns. - #[ts(optional = nullable)] - pub approvals_reviewer: Option, - #[ts(optional = nullable)] - pub sandbox: Option, - /// Named profile selection for the forked thread. Cannot be combined with - /// `sandbox`. Use bounded `modifications` for supported thread - /// adjustments instead of replacing the full permissions profile. - #[experimental("thread/fork.permissions")] - #[ts(optional = nullable)] - pub permissions: Option, - #[ts(optional = nullable)] - pub config: Option>, - #[ts(optional = nullable)] - pub base_instructions: Option, - #[ts(optional = nullable)] - pub developer_instructions: Option, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub ephemeral: bool, - /// When true, return only thread metadata and live fork state without - /// populating `thread.turns`. This is useful when the client plans to call - /// `thread/turns/list` immediately after forking. - #[experimental("thread/fork.excludeTurns")] - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub exclude_turns: bool, - /// If true, persist additional rollout EventMsg variants required to - /// reconstruct a richer thread history on subsequent resume/fork/read. - #[experimental("thread/fork.persistFullHistory")] - #[serde(default)] - pub persist_extended_history: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadForkResponse { - pub thread: Thread, - pub model: String, - pub model_provider: String, - pub service_tier: Option, - pub cwd: AbsolutePathBuf, - /// Instruction source files currently loaded for this thread. - #[serde(default)] - pub instruction_sources: Vec, - #[experimental(nested)] - pub approval_policy: AskForApproval, - /// Reviewer currently used for approval requests on this thread. - pub approvals_reviewer: ApprovalsReviewer, - /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. - pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. - #[experimental("thread/fork.permissionProfile")] - #[serde(default)] - pub permission_profile: Option, - /// Named or implicit built-in profile that produced the active - /// permissions, when known. - #[experimental("thread/fork.activePermissionProfile")] - #[serde(default)] - pub active_permission_profile: Option, - pub reasoning_effort: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadArchiveParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadArchiveResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadUnsubscribeParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadUnsubscribeResponse { - pub status: ThreadUnsubscribeStatus, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ThreadUnsubscribeStatus { - NotLoaded, - NotSubscribed, - Unsubscribed, -} - -/// Parameters for `thread/increment_elicitation`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadIncrementElicitationParams { - /// Thread whose out-of-band elicitation counter should be incremented. - pub thread_id: String, -} - -/// Response for `thread/increment_elicitation`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadIncrementElicitationResponse { - /// Current out-of-band elicitation count after the increment. - pub count: u64, - /// Whether timeout accounting is paused after applying the increment. - pub paused: bool, -} - -/// Parameters for `thread/decrement_elicitation`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadDecrementElicitationParams { - /// Thread whose out-of-band elicitation counter should be decremented. - pub thread_id: String, -} - -/// Response for `thread/decrement_elicitation`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadDecrementElicitationResponse { - /// Current out-of-band elicitation count after the decrement. - pub count: u64, - /// Whether timeout accounting remains paused after applying the decrement. - pub paused: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadSetNameParams { - pub thread_id: String, - pub name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadUnarchiveParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadSetNameResponse {} - -v2_enum_from_core! { - pub enum ThreadGoalStatus from CoreThreadGoalStatus { - Active, - Paused, - BudgetLimited, - Complete, - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoal { - pub thread_id: String, - pub objective: String, - pub status: ThreadGoalStatus, - #[ts(type = "number | null")] - pub token_budget: Option, - #[ts(type = "number")] - pub tokens_used: i64, - #[ts(type = "number")] - pub time_used_seconds: i64, - #[ts(type = "number")] - pub created_at: i64, - #[ts(type = "number")] - pub updated_at: i64, -} - -impl From for ThreadGoal { - fn from(value: codex_protocol::protocol::ThreadGoal) -> Self { - Self { - thread_id: value.thread_id.to_string(), - objective: value.objective, - status: value.status.into(), - token_budget: value.token_budget, - tokens_used: value.tokens_used, - time_used_seconds: value.time_used_seconds, - created_at: value.created_at, - updated_at: value.updated_at, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalSetParams { - pub thread_id: String, - #[ts(optional = nullable)] - pub objective: Option, - #[ts(optional = nullable)] - pub status: Option, - #[serde( - default, - deserialize_with = "super::serde_helpers::deserialize_double_option", - serialize_with = "super::serde_helpers::serialize_double_option", - skip_serializing_if = "Option::is_none" - )] - #[ts(optional = nullable, type = "number | null")] - pub token_budget: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalSetResponse { - pub goal: ThreadGoal, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalGetParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalGetResponse { - pub goal: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalClearParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalClearResponse { - pub cleared: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadMetadataUpdateParams { - pub thread_id: String, - /// Patch the stored Git metadata for this thread. - /// Omit a field to leave it unchanged, set it to `null` to clear it, or - /// provide a string to replace the stored value. - #[ts(optional = nullable)] - pub git_info: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadMetadataGitInfoUpdateParams { - /// Omit to leave the stored commit unchanged, set to `null` to clear it, - /// or provide a non-empty string to replace it. - #[serde( - default, - skip_serializing_if = "Option::is_none", - serialize_with = "super::serde_helpers::serialize_double_option", - deserialize_with = "super::serde_helpers::deserialize_double_option" - )] - #[ts(optional = nullable, type = "string | null")] - pub sha: Option>, - /// Omit to leave the stored branch unchanged, set to `null` to clear it, - /// or provide a non-empty string to replace it. - #[serde( - default, - skip_serializing_if = "Option::is_none", - serialize_with = "super::serde_helpers::serialize_double_option", - deserialize_with = "super::serde_helpers::deserialize_double_option" - )] - #[ts(optional = nullable, type = "string | null")] - pub branch: Option>, - /// Omit to leave the stored origin URL unchanged, set to `null` to clear it, - /// or provide a non-empty string to replace it. - #[serde( - default, - skip_serializing_if = "Option::is_none", - serialize_with = "super::serde_helpers::serialize_double_option", - deserialize_with = "super::serde_helpers::deserialize_double_option" - )] - #[ts(optional = nullable, type = "string | null")] - pub origin_url: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadMetadataUpdateResponse { - pub thread: Thread, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(rename_all = "lowercase")] -pub enum ThreadMemoryMode { - Enabled, - Disabled, -} - -impl ThreadMemoryMode { - pub fn as_str(self) -> &'static str { - match self { - Self::Enabled => "enabled", - Self::Disabled => "disabled", - } - } - - pub fn to_core(self) -> codex_protocol::protocol::ThreadMemoryMode { - match self { - Self::Enabled => codex_protocol::protocol::ThreadMemoryMode::Enabled, - Self::Disabled => codex_protocol::protocol::ThreadMemoryMode::Disabled, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadMemoryModeSetParams { - pub thread_id: String, - pub mode: ThreadMemoryMode, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadMemoryModeSetResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MemoryResetResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadUnarchiveResponse { - pub thread: Thread, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadCompactStartParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadCompactStartResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadShellCommandParams { - pub thread_id: String, - /// Shell command string evaluated by the thread's configured shell. - /// Unlike `command/exec`, this intentionally preserves shell syntax - /// such as pipes, redirects, and quoting. This runs unsandboxed with full - /// access rather than inheriting the thread sandbox policy. - pub command: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadShellCommandResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadApproveGuardianDeniedActionParams { - pub thread_id: String, - /// Serialized `codex_protocol::protocol::GuardianAssessmentEvent`. - pub event: JsonValue, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadApproveGuardianDeniedActionResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadBackgroundTerminalsCleanParams { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadBackgroundTerminalsCleanResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRollbackParams { - pub thread_id: String, - /// The number of turns to drop from the end of the thread. Must be >= 1. - /// - /// This only modifies the thread's history and does not revert local file changes - /// that have been made by the agent. Clients are responsible for reverting these changes. - pub num_turns: u32, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRollbackResponse { - /// The updated thread after applying the rollback, with `turns` populated. - /// - /// The ThreadItems stored in each Turn are lossy since we explicitly do not - /// persist all agent interactions, such as command executions. This is the same - /// behavior as `thread/resume`. - pub thread: Thread, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadListParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to a reasonable server-side value. - #[ts(optional = nullable)] - pub limit: Option, - /// Optional sort key; defaults to created_at. - #[ts(optional = nullable)] - pub sort_key: Option, - /// Optional sort direction; defaults to descending (newest first). - #[ts(optional = nullable)] - pub sort_direction: Option, - /// Optional provider filter; when set, only sessions recorded under these - /// providers are returned. When present but empty, includes all providers. - #[ts(optional = nullable)] - pub model_providers: Option>, - /// Optional source filter; when set, only sessions from these source kinds - /// are returned. When omitted or empty, defaults to interactive sources. - #[ts(optional = nullable)] - pub source_kinds: Option>, - /// Optional archived filter; when set to true, only archived threads are returned. - /// If false or null, only non-archived threads are returned. - #[ts(optional = nullable)] - pub archived: Option, - /// Optional cwd filter or filters; when set, only threads whose session cwd - /// exactly matches one of these paths are returned. - #[ts(optional = nullable, type = "string | Array | null")] - pub cwd: Option, - /// If true, return from the state DB without scanning JSONL rollouts to - /// repair thread metadata. Omitted or false preserves scan-and-repair - /// behavior. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub use_state_db_only: bool, - /// Optional substring filter for the extracted thread title. - #[ts(optional = nullable)] - pub search_term: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] -#[serde(untagged)] -pub enum ThreadListCwdFilter { - One(String), - Many(Vec), -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase", export_to = "v2/")] -pub enum ThreadSourceKind { - Cli, - #[serde(rename = "vscode")] - #[ts(rename = "vscode")] - VsCode, - Exec, - AppServer, - SubAgent, - SubAgentReview, - SubAgentCompact, - SubAgentThreadSpawn, - SubAgentOther, - Unknown, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub enum ThreadSortKey { - CreatedAt, - UpdatedAt, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub enum SortDirection { - Asc, - Desc, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadListResponse { - pub data: Vec, - /// Opaque cursor to pass to the next call to continue after the last item. - /// if None, there are no more items to return. - pub next_cursor: Option, - /// Opaque cursor to pass as `cursor` when reversing `sortDirection`. - /// This is only populated when the page contains at least one thread. - /// Use it with the opposite `sortDirection`; for timestamp sorts it anchors - /// at the start of the page timestamp so same-second updates are not skipped. - pub backwards_cursor: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadLoadedListParams { - /// Opaque pagination cursor returned by a previous call. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional page size; defaults to no limit. - #[ts(optional = nullable)] - pub limit: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadLoadedListResponse { - /// Thread ids for sessions currently loaded in memory. - pub data: Vec, - /// Opaque cursor to pass to the next call to continue after the last item. - /// if None, there are no more items to return. - pub next_cursor: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ThreadStatus { - NotLoaded, - Idle, - SystemError, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Active { - active_flags: Vec, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum ThreadActiveFlag { - WaitingOnApproval, - WaitingOnUserInput, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadReadParams { - pub thread_id: String, - /// When true, include turns and their items from rollout history. - #[serde(default)] - pub include_turns: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadReadResponse { - pub thread: Thread, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadTurnsListParams { - pub thread_id: String, - /// Opaque cursor to pass to the next call to continue after the last turn. - #[ts(optional = nullable)] - pub cursor: Option, - /// Optional turn page size. - #[ts(optional = nullable)] - pub limit: Option, - /// Optional turn pagination direction; defaults to descending. - #[ts(optional = nullable)] - pub sort_direction: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadTurnsListResponse { - pub data: Vec, - /// Opaque cursor to pass to the next call to continue after the last turn. - /// if None, there are no more turns to return. - pub next_cursor: Option, - /// Opaque cursor to pass as `cursor` when reversing `sortDirection`. - /// This is only populated when the page contains at least one turn. - /// Use it with the opposite `sortDirection` to include the anchor turn again - /// and catch updates to that turn. - pub backwards_cursor: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsListParams { - /// When empty, defaults to the current session working directory. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub cwds: Vec, - - /// When true, bypass the skills cache and re-scan skills from disk. - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub force_reload: bool, - - /// Optional per-cwd extra roots to scan as user-scoped skills. - #[serde(default)] - #[ts(optional = nullable)] - pub per_cwd_extra_user_roots: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsListExtraRootsForCwd { - pub cwd: PathBuf, - pub extra_user_roots: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsListResponse { - pub data: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HooksListParams { - /// When empty, defaults to the current session working directory. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub cwds: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HooksListResponse { - pub data: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceAddParams { - pub source: String, - #[ts(optional = nullable)] - pub ref_name: Option, - #[ts(optional = nullable)] - pub sparse_paths: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceAddResponse { - pub marketplace_name: String, - pub installed_root: AbsolutePathBuf, - pub already_added: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceRemoveParams { - pub marketplace_name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceRemoveResponse { - pub marketplace_name: String, - pub installed_root: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceUpgradeParams { - #[ts(optional = nullable)] - pub marketplace_name: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceUpgradeResponse { - pub selected_marketplaces: Vec, - pub upgraded_roots: Vec, - pub errors: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceUpgradeErrorInfo { - pub marketplace_name: String, - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginListParams { - /// Optional working directories used to discover repo marketplaces. When omitted, - /// only home-scoped marketplaces and the official curated marketplace are considered. - #[ts(optional = nullable)] - pub cwds: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginListResponse { - pub marketplaces: Vec, - #[serde(default)] - pub marketplace_load_errors: Vec, - #[serde(default)] - pub featured_plugin_ids: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceLoadErrorInfo { - pub marketplace_path: AbsolutePathBuf, - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginReadParams { - #[ts(optional = nullable)] - pub marketplace_path: Option, - #[ts(optional = nullable)] - pub remote_marketplace_name: Option, - pub plugin_name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginReadResponse { - pub plugin: PluginDetail, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginSkillReadParams { - pub remote_marketplace_name: String, - pub remote_plugin_id: String, - pub skill_name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginSkillReadResponse { - pub contents: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginShareSaveParams { - pub plugin_path: AbsolutePathBuf, - #[ts(optional = nullable)] - pub remote_plugin_id: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginShareSaveResponse { - pub remote_plugin_id: String, - pub share_url: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginShareListParams {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginShareListResponse { - pub data: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginShareDeleteParams { - pub remote_plugin_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginShareDeleteResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginShareListItem { - pub plugin: PluginSummary, - pub share_url: String, - pub local_plugin_path: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(rename_all = "snake_case")] -#[ts(export_to = "v2/")] -pub enum SkillScope { - User, - Repo, - System, - Admin, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillMetadata { - pub name: String, - pub description: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - /// Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description. - pub short_description: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub interface: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub dependencies: Option, - pub path: AbsolutePathBuf, - pub scope: SkillScope, - pub enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillInterface { - #[ts(optional)] - pub display_name: Option, - #[ts(optional)] - pub short_description: Option, - #[ts(optional)] - pub icon_small: Option, - #[ts(optional)] - pub icon_large: Option, - #[ts(optional)] - pub brand_color: Option, - #[ts(optional)] - pub default_prompt: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillDependencies { - pub tools: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillToolDependency { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub r#type: String, - pub value: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub transport: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub command: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub url: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillErrorInfo { - pub path: PathBuf, - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsListEntry { - pub cwd: PathBuf, - pub skills: Vec, - pub errors: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HooksListEntry { - pub cwd: PathBuf, - pub hooks: Vec, - pub warnings: Vec, - pub errors: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HookMetadata { - pub key: String, - pub event_name: HookEventName, - pub handler_type: HookHandlerType, - pub matcher: Option, - pub command: Option, - pub timeout_sec: u64, - pub status_message: Option, - pub source_path: AbsolutePathBuf, - pub source: HookSource, - pub plugin_id: Option, - pub display_order: i64, - pub enabled: bool, - pub is_managed: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HookErrorInfo { - pub path: PathBuf, - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginMarketplaceEntry { - pub name: String, - /// Local marketplace file path when the marketplace is backed by a local file. - /// Remote-only catalog marketplaces do not have a local path. - pub path: Option, - pub interface: Option, - pub plugins: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MarketplaceInterface { - pub display_name: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[ts(export_to = "v2/")] -pub enum PluginInstallPolicy { - #[serde(rename = "NOT_AVAILABLE")] - #[ts(rename = "NOT_AVAILABLE")] - NotAvailable, - #[serde(rename = "AVAILABLE")] - #[ts(rename = "AVAILABLE")] - Available, - #[serde(rename = "INSTALLED_BY_DEFAULT")] - #[ts(rename = "INSTALLED_BY_DEFAULT")] - InstalledByDefault, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[ts(export_to = "v2/")] -pub enum PluginAuthPolicy { - #[serde(rename = "ON_INSTALL")] - #[ts(rename = "ON_INSTALL")] - OnInstall, - #[serde(rename = "ON_USE")] - #[ts(rename = "ON_USE")] - OnUse, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema, TS)] -#[ts(export_to = "v2/")] -pub enum PluginAvailability { - /// Plugin-service currently sends `"ENABLED"` for available remote plugins. - /// Codex app-server exposes `"AVAILABLE"` in its API; the alias keeps - /// decoding compatible with that upstream response. - #[serde(rename = "AVAILABLE", alias = "ENABLED")] - #[ts(rename = "AVAILABLE")] - #[default] - Available, - #[serde(rename = "DISABLED_BY_ADMIN")] - #[ts(rename = "DISABLED_BY_ADMIN")] - DisabledByAdmin, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginSummary { - pub id: String, - pub name: String, - pub source: PluginSource, - pub installed: bool, - pub enabled: bool, - pub install_policy: PluginInstallPolicy, - pub auth_policy: PluginAuthPolicy, - /// Availability state for installing and using the plugin. - #[serde(default)] - pub availability: PluginAvailability, - pub interface: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginDetail { - pub marketplace_name: String, - pub marketplace_path: Option, - pub summary: PluginSummary, - pub description: Option, - pub skills: Vec, - pub apps: Vec, - pub mcp_servers: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillSummary { - pub name: String, - pub description: String, - pub short_description: Option, - pub interface: Option, - pub path: Option, - pub enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginInterface { - pub display_name: Option, - pub short_description: Option, - pub long_description: Option, - pub developer_name: Option, - pub category: Option, - pub capabilities: Vec, - pub website_url: Option, - pub privacy_policy_url: Option, - pub terms_of_service_url: Option, - /// Starter prompts for the plugin. Capped at 3 entries with a maximum of - /// 128 characters per entry. - pub default_prompt: Option>, - pub brand_color: Option, - /// Local composer icon path, resolved from the installed plugin package. - pub composer_icon: Option, - /// Remote composer icon URL from the plugin catalog. - pub composer_icon_url: Option, - /// Local logo path, resolved from the installed plugin package. - pub logo: Option, - /// Remote logo URL from the plugin catalog. - pub logo_url: Option, - /// Local screenshot paths, resolved from the installed plugin package. - pub screenshots: Vec, - /// Remote screenshot URLs from the plugin catalog. - pub screenshot_urls: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PluginSource { - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Local { path: AbsolutePathBuf }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Git { - url: String, - path: Option, - ref_name: Option, - sha: Option, - }, - /// The plugin is available in the remote catalog. Download metadata is - /// kept server-side and is not exposed through the app-server API. - Remote, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsConfigWriteParams { - /// Path-based selector. - #[ts(optional = nullable)] - pub path: Option, - /// Name-based selector. - #[ts(optional = nullable)] - pub name: Option, - pub enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct SkillsConfigWriteResponse { - pub effective_enabled: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginInstallParams { - #[ts(optional = nullable)] - pub marketplace_path: Option, - #[ts(optional = nullable)] - pub remote_marketplace_name: Option, - pub plugin_name: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginInstallResponse { - pub auth_policy: PluginAuthPolicy, - pub apps_needing_auth: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginUninstallParams { - pub plugin_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PluginUninstallResponse {} - -impl From for SkillMetadata { - fn from(value: CoreSkillMetadata) -> Self { - Self { - name: value.name, - description: value.description, - short_description: value.short_description, - interface: value.interface.map(SkillInterface::from), - dependencies: value.dependencies.map(SkillDependencies::from), - path: value.path, - scope: value.scope.into(), - enabled: true, - } - } -} - -impl From for SkillInterface { - fn from(value: CoreSkillInterface) -> Self { - Self { - display_name: value.display_name, - short_description: value.short_description, - brand_color: value.brand_color, - default_prompt: value.default_prompt, - icon_small: value.icon_small, - icon_large: value.icon_large, - } - } -} - -impl From for SkillDependencies { - fn from(value: CoreSkillDependencies) -> Self { - Self { - tools: value - .tools - .into_iter() - .map(SkillToolDependency::from) - .collect(), - } - } -} - -impl From for SkillToolDependency { - fn from(value: CoreSkillToolDependency) -> Self { - Self { - r#type: value.r#type, - value: value.value, - description: value.description, - transport: value.transport, - command: value.command, - url: value.url, - } - } -} - -impl From for SkillScope { - fn from(value: CoreSkillScope) -> Self { - match value { - CoreSkillScope::User => Self::User, - CoreSkillScope::Repo => Self::Repo, - CoreSkillScope::System => Self::System, - CoreSkillScope::Admin => Self::Admin, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct Thread { - pub id: String, - /// Source thread id when this thread was created by forking another thread. - pub forked_from_id: Option, - /// Usually the first user message in the thread, if available. - pub preview: String, - /// Whether the thread is ephemeral and should not be materialized on disk. - pub ephemeral: bool, - /// Model provider used for this thread (for example, 'openai'). - pub model_provider: String, - /// Unix timestamp (in seconds) when the thread was created. - #[ts(type = "number")] - pub created_at: i64, - /// Unix timestamp (in seconds) when the thread was last updated. - #[ts(type = "number")] - pub updated_at: i64, - /// Current runtime status for the thread. - pub status: ThreadStatus, - /// [UNSTABLE] Path to the thread on disk. - pub path: Option, - /// Working directory captured for the thread. - pub cwd: AbsolutePathBuf, - /// Version of the CLI that created the thread. - pub cli_version: String, - /// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). - pub source: SessionSource, - /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. - pub agent_nickname: Option, - /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. - pub agent_role: Option, - /// Optional Git metadata captured when the thread was created. - pub git_info: Option, - /// Optional user-facing thread title. - pub name: Option, - /// Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` - /// (when `includeTurns` is true) responses. - /// For all other responses and notifications returning a Thread, - /// the turns field will be an empty list. - pub turns: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AccountUpdatedNotification { - pub auth_mode: Option, - pub plan_type: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadTokenUsageUpdatedNotification { - pub thread_id: String, - pub turn_id: String, - pub token_usage: ThreadTokenUsage, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadTokenUsage { - pub total: TokenUsageBreakdown, - pub last: TokenUsageBreakdown, - // TODO(aibrahim): make this not optional - #[ts(type = "number | null")] - pub model_context_window: Option, -} - -impl From for ThreadTokenUsage { - fn from(value: CoreTokenUsageInfo) -> Self { - Self { - total: value.total_token_usage.into(), - last: value.last_token_usage.into(), - model_context_window: value.model_context_window, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TokenUsageBreakdown { - #[ts(type = "number")] - pub total_tokens: i64, - #[ts(type = "number")] - pub input_tokens: i64, - #[ts(type = "number")] - pub cached_input_tokens: i64, - #[ts(type = "number")] - pub output_tokens: i64, - #[ts(type = "number")] - pub reasoning_output_tokens: i64, -} - -impl From for TokenUsageBreakdown { - fn from(value: CoreTokenUsage) -> Self { - Self { - total_tokens: value.total_tokens, - input_tokens: value.input_tokens, - cached_input_tokens: value.cached_input_tokens, - output_tokens: value.output_tokens, - reasoning_output_tokens: value.reasoning_output_tokens, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct Turn { - pub id: String, - /// Only populated on a `thread/resume` or `thread/fork` response. - /// For all other responses and notifications returning a Turn, - /// the items field will be an empty list. - pub items: Vec, - pub status: TurnStatus, - /// Only populated when the Turn's status is failed. - pub error: Option, - /// Unix timestamp (in seconds) when the turn started. - #[ts(type = "number | null")] - pub started_at: Option, - /// Unix timestamp (in seconds) when the turn completed. - #[ts(type = "number | null")] - pub completed_at: Option, - /// Duration between turn start and completion in milliseconds, if known. - #[ts(type = "number | null")] - pub duration_ms: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MemoryCitation { - pub entries: Vec, - pub thread_ids: Vec, -} - -impl From for MemoryCitation { - fn from(value: CoreMemoryCitation) -> Self { - Self { - entries: value.entries.into_iter().map(Into::into).collect(), - thread_ids: value.rollout_ids, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct MemoryCitationEntry { - pub path: String, - pub line_start: u32, - pub line_end: u32, - pub note: String, -} - -impl From for MemoryCitationEntry { - fn from(value: CoreMemoryCitationEntry) -> Self { - Self { - path: value.path, - line_start: value.line_start, - line_end: value.line_end, - note: value.note, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Error)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -#[error("{message}")] -pub struct TurnError { - pub message: String, - pub codex_error_info: Option, - #[serde(default)] - pub additional_details: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ErrorNotification { - pub error: TurnError, - // Set to true if the error is transient and the app-server process will automatically retry. - // If true, this will not interrupt a turn. - pub will_retry: bool, - pub thread_id: String, - pub turn_id: String, -} - -/// EXPERIMENTAL - thread realtime audio chunk. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeAudioChunk { - pub data: String, - pub sample_rate: u32, - pub num_channels: u16, - pub samples_per_channel: Option, - pub item_id: Option, -} - -impl From for ThreadRealtimeAudioChunk { - fn from(value: CoreRealtimeAudioFrame) -> Self { - let CoreRealtimeAudioFrame { - data, - sample_rate, - num_channels, - samples_per_channel, - item_id, - } = value; - Self { - data, - sample_rate, - num_channels, - samples_per_channel, - item_id, - } - } -} - -impl From for CoreRealtimeAudioFrame { - fn from(value: ThreadRealtimeAudioChunk) -> Self { - let ThreadRealtimeAudioChunk { - data, - sample_rate, - num_channels, - samples_per_channel, - item_id, - } = value; - Self { - data, - sample_rate, - num_channels, - samples_per_channel, - item_id, - } - } -} - -/// EXPERIMENTAL - start a thread-scoped realtime session. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeStartParams { - pub thread_id: String, - /// Selects text or audio output for the realtime session. Transport and voice stay - /// independent so clients can choose how they connect separately from what the model emits. - pub output_modality: RealtimeOutputModality, - #[serde( - default, - deserialize_with = "super::serde_helpers::deserialize_double_option", - serialize_with = "super::serde_helpers::serialize_double_option", - skip_serializing_if = "Option::is_none" - )] - #[ts(optional = nullable)] - pub prompt: Option>, - #[ts(optional = nullable)] - pub realtime_session_id: Option, - #[ts(optional = nullable)] - pub transport: Option, - #[ts(optional = nullable)] - pub voice: Option, -} - -/// EXPERIMENTAL - transport used by thread realtime. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(export_to = "v2/", tag = "type")] -pub enum ThreadRealtimeStartTransport { - Websocket, - Webrtc { - /// SDP offer generated by a WebRTC RTCPeerConnection after configuring audio and the - /// realtime events data channel. - sdp: String, - }, -} - -/// EXPERIMENTAL - response for starting thread realtime. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeStartResponse {} - -/// EXPERIMENTAL - append audio input to thread realtime. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeAppendAudioParams { - pub thread_id: String, - pub audio: ThreadRealtimeAudioChunk, -} - -/// EXPERIMENTAL - response for appending realtime audio input. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeAppendAudioResponse {} - -/// EXPERIMENTAL - append text input to thread realtime. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeAppendTextParams { - pub thread_id: String, - pub text: String, -} - -/// EXPERIMENTAL - response for appending realtime text input. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeAppendTextResponse {} - -/// EXPERIMENTAL - stop thread realtime. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeStopParams { - pub thread_id: String, -} - -/// EXPERIMENTAL - response for stopping thread realtime. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeStopResponse {} - -/// EXPERIMENTAL - list voices supported by thread realtime. -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeListVoicesParams {} - -/// EXPERIMENTAL - response for listing supported realtime voices. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeListVoicesResponse { - pub voices: RealtimeVoicesList, -} - -/// EXPERIMENTAL - emitted when thread realtime startup is accepted. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeStartedNotification { - pub thread_id: String, - pub realtime_session_id: Option, - pub version: RealtimeConversationVersion, -} - -/// EXPERIMENTAL - raw non-audio thread realtime item emitted by the backend. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeItemAddedNotification { - pub thread_id: String, - pub item: JsonValue, -} - -/// EXPERIMENTAL - flat transcript delta emitted whenever realtime -/// transcript text changes. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeTranscriptDeltaNotification { - pub thread_id: String, - pub role: String, - /// Live transcript delta from the realtime event. - pub delta: String, -} - -/// EXPERIMENTAL - final transcript text emitted when realtime completes -/// a transcript part. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeTranscriptDoneNotification { - pub thread_id: String, - pub role: String, - /// Final complete text for the transcript part. - pub text: String, -} - -/// EXPERIMENTAL - streamed output audio emitted by thread realtime. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeOutputAudioDeltaNotification { - pub thread_id: String, - pub audio: ThreadRealtimeAudioChunk, -} - -/// EXPERIMENTAL - emitted with the remote SDP for a WebRTC realtime session. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeSdpNotification { - pub thread_id: String, - pub sdp: String, -} - -/// EXPERIMENTAL - emitted when thread realtime encounters an error. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeErrorNotification { - pub thread_id: String, - pub message: String, -} - -/// EXPERIMENTAL - emitted when thread realtime transport closes. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadRealtimeClosedNotification { - pub thread_id: String, - pub reason: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum TurnStatus { - Completed, - Interrupted, - Failed, - InProgress, -} - -// Turn APIs -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnEnvironmentParams { - pub environment_id: String, - pub cwd: AbsolutePathBuf, -} - -#[derive( - Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, -)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnStartParams { - pub thread_id: String, - pub input: Vec, - /// Optional turn-scoped Responses API client metadata. - #[experimental("turn/start.responsesapiClientMetadata")] - #[ts(optional = nullable)] - pub responsesapi_client_metadata: Option>, - /// Optional turn-scoped environments. - /// - /// Omitted uses the thread sticky environments. Empty disables - /// environment access for this turn. Non-empty selects the first - /// environment as the current turn environment for this turn. - #[experimental("turn/start.environments")] - #[ts(optional = nullable)] - pub environments: Option>, - /// Override the working directory for this turn and subsequent turns. - #[ts(optional = nullable)] - pub cwd: Option, - /// Override the approval policy for this turn and subsequent turns. - #[experimental(nested)] - #[ts(optional = nullable)] - pub approval_policy: Option, - /// Override where approval requests are routed for review on this turn and - /// subsequent turns. - #[ts(optional = nullable)] - pub approvals_reviewer: Option, - /// Override the sandbox policy for this turn and subsequent turns. - #[ts(optional = nullable)] - pub sandbox_policy: Option, - /// Select a named permissions profile for this turn and subsequent turns. - /// Cannot be combined with `sandboxPolicy`. Use bounded `modifications` - /// for supported turn adjustments instead of replacing the full - /// permissions profile. - #[experimental("turn/start.permissions")] - #[ts(optional = nullable)] - pub permissions: Option, - /// Override the model for this turn and subsequent turns. - #[ts(optional = nullable)] - pub model: Option, - /// Override the service tier for this turn and subsequent turns. - #[serde( - default, - deserialize_with = "super::serde_helpers::deserialize_double_option", - serialize_with = "super::serde_helpers::serialize_double_option", - skip_serializing_if = "Option::is_none" - )] - #[ts(optional = nullable)] - pub service_tier: Option>, - /// Override the reasoning effort for this turn and subsequent turns. - #[ts(optional = nullable)] - pub effort: Option, - /// Override the reasoning summary for this turn and subsequent turns. - #[ts(optional = nullable)] - pub summary: Option, - /// Override the personality for this turn and subsequent turns. - #[ts(optional = nullable)] - pub personality: Option, - /// Optional JSON Schema used to constrain the final assistant message for - /// this turn. - #[ts(optional = nullable)] - pub output_schema: Option, - - /// EXPERIMENTAL - Set a pre-set collaboration mode. - /// Takes precedence over model, reasoning_effort, and developer instructions if set. - /// - /// For `collaboration_mode.settings.developer_instructions`, `null` means - /// "use the built-in instructions for the selected mode". - #[experimental("turn/start.collaborationMode")] - #[ts(optional = nullable)] - pub collaboration_mode: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReviewStartParams { - pub thread_id: String, - pub target: ReviewTarget, - - /// Where to run the review: inline (default) on the current thread or - /// detached on a new thread (returned in `reviewThreadId`). - #[serde(default)] - #[ts(optional = nullable)] - pub delivery: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReviewStartResponse { - pub turn: Turn, - /// Identifies the thread where the review runs. - /// - /// For inline reviews, this is the original thread id. - /// For detached reviews, this is the id of the new review thread. - pub review_thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type", export_to = "v2/")] -pub enum ReviewTarget { - /// Review the working tree: staged, unstaged, and untracked files. - UncommittedChanges, - - /// Review changes between the current branch and the given base branch. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - BaseBranch { branch: String }, - - /// Review the changes introduced by a specific commit. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Commit { - sha: String, - /// Optional human-readable label (e.g., commit subject) for UIs. - title: Option, - }, - - /// Arbitrary instructions, equivalent to the old free-form prompt. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Custom { instructions: String }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnStartResponse { - pub turn: Turn, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadInjectItemsParams { - pub thread_id: String, - /// Raw Responses API items to append to the thread's model-visible history. - pub items: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadInjectItemsResponse {} - -#[derive( - Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, -)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnSteerParams { - pub thread_id: String, - pub input: Vec, - /// Optional turn-scoped Responses API client metadata. - #[experimental("turn/steer.responsesapiClientMetadata")] - #[ts(optional = nullable)] - pub responsesapi_client_metadata: Option>, - /// Required active turn id precondition. The request fails when it does not - /// match the currently active turn. - pub expected_turn_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnSteerResponse { - pub turn_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnInterruptParams { - pub thread_id: String, - pub turn_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnInterruptResponse {} - -// User input types -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ByteRange { - pub start: usize, - pub end: usize, -} - -impl From for ByteRange { - fn from(value: CoreByteRange) -> Self { - Self { - start: value.start, - end: value.end, - } - } -} - -impl From for CoreByteRange { - fn from(value: ByteRange) -> Self { - Self { - start: value.start, - end: value.end, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TextElement { - /// Byte range in the parent `text` buffer that this element occupies. - pub byte_range: ByteRange, - /// Optional human-readable placeholder for the element, displayed in the UI. - placeholder: Option, -} - -impl TextElement { - pub fn new(byte_range: ByteRange, placeholder: Option) -> Self { - Self { - byte_range, - placeholder, - } - } - - pub fn set_placeholder(&mut self, placeholder: Option) { - self.placeholder = placeholder; - } - - pub fn placeholder(&self) -> Option<&str> { - self.placeholder.as_deref() - } -} - -impl From for TextElement { - fn from(value: CoreTextElement) -> Self { - Self::new( - value.byte_range.into(), - value._placeholder_for_conversion_only().map(str::to_string), - ) - } -} - -impl From for CoreTextElement { - fn from(value: TextElement) -> Self { - Self::new(value.byte_range.into(), value.placeholder) - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum UserInput { - Text { - text: String, - /// UI-defined spans within `text` used to render or persist special elements. - #[serde(default)] - text_elements: Vec, - }, - Image { - url: String, - }, - LocalImage { - path: PathBuf, - }, - Skill { - name: String, - path: PathBuf, - }, - Mention { - name: String, - path: String, - }, -} - -impl UserInput { - pub fn into_core(self) -> CoreUserInput { - match self { - UserInput::Text { - text, - text_elements, - } => CoreUserInput::Text { - text, - text_elements: text_elements.into_iter().map(Into::into).collect(), - }, - UserInput::Image { url } => CoreUserInput::Image { image_url: url }, - UserInput::LocalImage { path } => CoreUserInput::LocalImage { path }, - UserInput::Skill { name, path } => CoreUserInput::Skill { name, path }, - UserInput::Mention { name, path } => CoreUserInput::Mention { name, path }, - } - } -} - -impl From for UserInput { - fn from(value: CoreUserInput) -> Self { - match value { - CoreUserInput::Text { - text, - text_elements, - } => UserInput::Text { - text, - text_elements: text_elements.into_iter().map(Into::into).collect(), - }, - CoreUserInput::Image { image_url } => UserInput::Image { url: image_url }, - CoreUserInput::LocalImage { path } => UserInput::LocalImage { path }, - CoreUserInput::Skill { name, path } => UserInput::Skill { name, path }, - CoreUserInput::Mention { name, path } => UserInput::Mention { name, path }, - _ => unreachable!("unsupported user input variant"), - } - } -} - -impl UserInput { - pub fn text_char_count(&self) -> usize { - match self { - UserInput::Text { text, .. } => text.chars().count(), - UserInput::Image { .. } - | UserInput::LocalImage { .. } - | UserInput::Skill { .. } - | UserInput::Mention { .. } => 0, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ThreadItem { - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - UserMessage { id: String, content: Vec }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - HookPrompt { - id: String, - fragments: Vec, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AgentMessage { - id: String, - text: String, - #[serde(default)] - phase: Option, - #[serde(default)] - memory_citation: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - /// EXPERIMENTAL - proposed plan item content. The completed plan item is - /// authoritative and may not match the concatenation of `PlanDelta` text. - Plan { id: String, text: String }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Reasoning { - id: String, - #[serde(default)] - summary: Vec, - #[serde(default)] - content: Vec, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - CommandExecution { - id: String, - /// The command to be executed. - command: String, - /// The command's working directory. - cwd: AbsolutePathBuf, - /// Identifier for the underlying PTY process (when available). - process_id: Option, - #[serde(default)] - source: CommandExecutionSource, - status: CommandExecutionStatus, - /// A best-effort parsing of the command to understand the action(s) it will perform. - /// This returns a list of CommandAction objects because a single shell command may - /// be composed of many commands piped together. - command_actions: Vec, - /// The command's output, aggregated from stdout and stderr. - aggregated_output: Option, - /// The command's exit code. - exit_code: Option, - /// The duration of the command execution in milliseconds. - #[ts(type = "number | null")] - duration_ms: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - FileChange { - id: String, - changes: Vec, - status: PatchApplyStatus, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - McpToolCall { - id: String, - server: String, - tool: String, - status: McpToolCallStatus, - arguments: JsonValue, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - mcp_app_resource_uri: Option, - result: Option>, - error: Option, - /// The duration of the MCP tool call in milliseconds. - #[ts(type = "number | null")] - duration_ms: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - DynamicToolCall { - id: String, - namespace: Option, - tool: String, - arguments: JsonValue, - status: DynamicToolCallStatus, - content_items: Option>, - success: Option, - /// The duration of the dynamic tool call in milliseconds. - #[ts(type = "number | null")] - duration_ms: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - CollabAgentToolCall { - /// Unique identifier for this collab tool call. - id: String, - /// Name of the collab tool that was invoked. - tool: CollabAgentTool, - /// Current status of the collab tool call. - status: CollabAgentToolCallStatus, - /// Thread ID of the agent issuing the collab request. - sender_thread_id: String, - /// Thread ID of the receiving agent, when applicable. In case of spawn operation, - /// this corresponds to the newly spawned agent. - receiver_thread_ids: Vec, - /// Prompt text sent as part of the collab tool call, when available. - prompt: Option, - /// Model requested for the spawned agent, when applicable. - model: Option, - /// Reasoning effort requested for the spawned agent, when applicable. - reasoning_effort: Option, - /// Last known status of the target agents, when available. - agents_states: HashMap, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - WebSearch { - id: String, - query: String, - action: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ImageView { id: String, path: AbsolutePathBuf }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ImageGeneration { - id: String, - status: String, - revised_prompt: Option, - result: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - saved_path: Option, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - EnteredReviewMode { id: String, review: String }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ExitedReviewMode { id: String, review: String }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ContextCompaction { id: String }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase", export_to = "v2/")] -pub struct HookPromptFragment { - pub text: String, - pub hook_run_id: String, -} - -impl ThreadItem { - pub fn id(&self) -> &str { - match self { - ThreadItem::UserMessage { id, .. } - | ThreadItem::HookPrompt { id, .. } - | ThreadItem::AgentMessage { id, .. } - | ThreadItem::Plan { id, .. } - | ThreadItem::Reasoning { id, .. } - | ThreadItem::CommandExecution { id, .. } - | ThreadItem::FileChange { id, .. } - | ThreadItem::McpToolCall { id, .. } - | ThreadItem::DynamicToolCall { id, .. } - | ThreadItem::CollabAgentToolCall { id, .. } - | ThreadItem::WebSearch { id, .. } - | ThreadItem::ImageView { id, .. } - | ThreadItem::ImageGeneration { id, .. } - | ThreadItem::EnteredReviewMode { id, .. } - | ThreadItem::ExitedReviewMode { id, .. } - | ThreadItem::ContextCompaction { id, .. } => id, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// [UNSTABLE] Lifecycle state for an approval auto-review. -pub enum GuardianApprovalReviewStatus { - InProgress, - Approved, - Denied, - TimedOut, - Aborted, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// [UNSTABLE] Source that produced a terminal approval auto-review decision. -pub enum AutoReviewDecisionSource { - Agent, -} - -impl From for AutoReviewDecisionSource { - fn from(value: CoreGuardianAssessmentDecisionSource) -> Self { - match value { - CoreGuardianAssessmentDecisionSource::Agent => Self::Agent, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -/// [UNSTABLE] Risk level assigned by approval auto-review. -pub enum GuardianRiskLevel { - Low, - Medium, - High, - Critical, -} - -impl From for GuardianRiskLevel { - fn from(value: CoreGuardianRiskLevel) -> Self { - match value { - CoreGuardianRiskLevel::Low => Self::Low, - CoreGuardianRiskLevel::Medium => Self::Medium, - CoreGuardianRiskLevel::High => Self::High, - CoreGuardianRiskLevel::Critical => Self::Critical, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -/// [UNSTABLE] Authorization level assigned by approval auto-review. -pub enum GuardianUserAuthorization { - Unknown, - Low, - Medium, - High, -} - -impl From for GuardianUserAuthorization { - fn from(value: CoreGuardianUserAuthorization) -> Self { - match value { - CoreGuardianUserAuthorization::Unknown => Self::Unknown, - CoreGuardianUserAuthorization::Low => Self::Low, - CoreGuardianUserAuthorization::Medium => Self::Medium, - CoreGuardianUserAuthorization::High => Self::High, - } - } -} - -/// [UNSTABLE] Temporary approval auto-review payload used by -/// `item/autoApprovalReview/*` notifications. This shape is expected to change -/// soon. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GuardianApprovalReview { - pub status: GuardianApprovalReviewStatus, - pub risk_level: Option, - pub user_authorization: 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: AbsolutePathBuf, -} - -#[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: AbsolutePathBuf, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GuardianApplyPatchReviewAction { - pub cwd: AbsolutePathBuf, - 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, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GuardianRequestPermissionsReviewAction { - pub reason: Option, - pub permissions: RequestPermissionProfile, -} - -#[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: AbsolutePathBuf, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Execve { - source: GuardianCommandSource, - program: String, - argv: Vec, - cwd: AbsolutePathBuf, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - ApplyPatch { - cwd: AbsolutePathBuf, - 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, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - RequestPermissions { - reason: Option, - permissions: RequestPermissionProfile, - }, -} - -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, - }, - CoreGuardianAssessmentAction::RequestPermissions { - reason, - permissions, - } => Self::RequestPermissions { - reason, - permissions: permissions.into(), - }, - } - } -} - -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, - }, - GuardianApprovalReviewAction::RequestPermissions { - reason, - permissions, - } => Self::RequestPermissions { - reason, - permissions: permissions.into(), - }, - } - } -} - -#[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 WebSearchAction { - Search { - query: Option, - queries: Option>, - }, - OpenPage { - url: Option, - }, - FindInPage { - url: Option, - pattern: Option, - }, - #[serde(other)] - Other, -} - -impl From for WebSearchAction { - fn from(value: codex_protocol::models::WebSearchAction) -> Self { - match value { - codex_protocol::models::WebSearchAction::Search { query, queries } => { - WebSearchAction::Search { query, queries } - } - codex_protocol::models::WebSearchAction::OpenPage { url } => { - WebSearchAction::OpenPage { url } - } - codex_protocol::models::WebSearchAction::FindInPage { url, pattern } => { - WebSearchAction::FindInPage { url, pattern } - } - codex_protocol::models::WebSearchAction::Other => WebSearchAction::Other, - } - } -} - -impl From for ThreadItem { - fn from(value: CoreTurnItem) -> Self { - match value { - CoreTurnItem::UserMessage(user) => ThreadItem::UserMessage { - id: user.id, - content: user.content.into_iter().map(UserInput::from).collect(), - }, - CoreTurnItem::HookPrompt(hook_prompt) => ThreadItem::HookPrompt { - id: hook_prompt.id, - fragments: hook_prompt - .fragments - .into_iter() - .map(HookPromptFragment::from) - .collect(), - }, - CoreTurnItem::AgentMessage(agent) => { - let text = agent - .content - .into_iter() - .map(|entry| match entry { - CoreAgentMessageContent::Text { text } => text, - }) - .collect::(); - ThreadItem::AgentMessage { - id: agent.id, - text, - phase: agent.phase, - memory_citation: agent.memory_citation.map(Into::into), - } - } - CoreTurnItem::Plan(plan) => ThreadItem::Plan { - id: plan.id, - text: plan.text, - }, - CoreTurnItem::Reasoning(reasoning) => ThreadItem::Reasoning { - id: reasoning.id, - summary: reasoning.summary_text, - content: reasoning.raw_content, - }, - CoreTurnItem::WebSearch(search) => ThreadItem::WebSearch { - id: search.id, - query: search.query, - action: Some(WebSearchAction::from(search.action)), - }, - CoreTurnItem::ImageView(image) => ThreadItem::ImageView { - id: image.id, - path: image.path, - }, - CoreTurnItem::ImageGeneration(image) => ThreadItem::ImageGeneration { - id: image.id, - status: image.status, - revised_prompt: image.revised_prompt, - result: image.result, - saved_path: image.saved_path, - }, - CoreTurnItem::FileChange(file_change) => ThreadItem::FileChange { - id: file_change.id, - changes: convert_patch_changes(&file_change.changes), - status: file_change - .status - .as_ref() - .map(PatchApplyStatus::from) - .unwrap_or(PatchApplyStatus::InProgress), - }, - CoreTurnItem::ContextCompaction(compaction) => { - ThreadItem::ContextCompaction { id: compaction.id } - } - } - } -} - -impl From for HookPromptFragment { - fn from(value: codex_protocol::items::HookPromptFragment) -> Self { - Self { - text: value.text, - hook_run_id: value.hook_run_id, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CommandExecutionStatus { - InProgress, - Completed, - Failed, - Declined, -} - -impl From for CommandExecutionStatus { - fn from(value: CoreExecCommandStatus) -> Self { - Self::from(&value) - } -} - -impl From<&CoreExecCommandStatus> for CommandExecutionStatus { - fn from(value: &CoreExecCommandStatus) -> Self { - match value { - CoreExecCommandStatus::Completed => CommandExecutionStatus::Completed, - CoreExecCommandStatus::Failed => CommandExecutionStatus::Failed, - CoreExecCommandStatus::Declined => CommandExecutionStatus::Declined, - } - } -} - -v2_enum_from_core! { - #[derive(Default)] - pub enum CommandExecutionSource from CoreExecCommandSource { - #[default] - Agent, - UserShell, - UnifiedExecStartup, - UnifiedExecInteraction, - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CollabAgentTool { - SpawnAgent, - SendInput, - ResumeAgent, - Wait, - CloseAgent, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FileUpdateChange { - pub path: String, - pub kind: PatchChangeKind, - pub diff: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PatchChangeKind { - Add, - Delete, - Update { move_path: Option }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum PatchApplyStatus { - InProgress, - Completed, - Failed, - Declined, -} - -impl From for PatchApplyStatus { - fn from(value: CorePatchApplyStatus) -> Self { - Self::from(&value) - } -} - -impl From<&CorePatchApplyStatus> for PatchApplyStatus { - fn from(value: &CorePatchApplyStatus) -> Self { - match value { - CorePatchApplyStatus::Completed => PatchApplyStatus::Completed, - CorePatchApplyStatus::Failed => PatchApplyStatus::Failed, - CorePatchApplyStatus::Declined => PatchApplyStatus::Declined, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum McpToolCallStatus { - InProgress, - Completed, - Failed, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum DynamicToolCallStatus { - InProgress, - Completed, - Failed, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CollabAgentToolCallStatus { - InProgress, - Completed, - Failed, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum CollabAgentStatus { - PendingInit, - Running, - Interrupted, - Completed, - Errored, - Shutdown, - NotFound, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CollabAgentState { - pub status: CollabAgentStatus, - pub message: Option, -} - -impl From for CollabAgentState { - fn from(value: CoreAgentStatus) -> Self { - match value { - CoreAgentStatus::PendingInit => Self { - status: CollabAgentStatus::PendingInit, - message: None, - }, - CoreAgentStatus::Running => Self { - status: CollabAgentStatus::Running, - message: None, - }, - CoreAgentStatus::Interrupted => Self { - status: CollabAgentStatus::Interrupted, - message: None, - }, - CoreAgentStatus::Completed(message) => Self { - status: CollabAgentStatus::Completed, - message, - }, - CoreAgentStatus::Errored(message) => Self { - status: CollabAgentStatus::Errored, - message: Some(message), - }, - CoreAgentStatus::Shutdown => Self { - status: CollabAgentStatus::Shutdown, - message: None, - }, - CoreAgentStatus::NotFound => Self { - status: CollabAgentStatus::NotFound, - message: None, - }, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpToolCallResult { - // NOTE: `rmcp::model::Content` (and its `RawContent` variants) would be a more precise Rust - // representation of MCP content blocks. We intentionally use `serde_json::Value` here because - // this crate exports JSON schema + TS types (`schemars`/`ts-rs`), and the rmcp model types - // aren't set up to be schema/TS friendly (and would introduce heavier coupling to rmcp's Rust - // representations). Using `JsonValue` keeps the payload wire-shaped and easy to export. - pub content: Vec, - pub structured_content: Option, - #[serde(rename = "_meta")] - #[ts(rename = "_meta")] - pub meta: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpToolCallError { - pub message: String, -} - -// === Server Notifications === -// Thread/Turn lifecycle notifications and item progress events -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadStartedNotification { - pub thread: Thread, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadStatusChangedNotification { - pub thread_id: String, - pub status: ThreadStatus, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadArchivedNotification { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadUnarchivedNotification { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadClosedNotification { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// Notification emitted when watched local skill files change. -/// -/// Treat this as an invalidation signal and re-run `skills/list` with the -/// client's current parameters when refreshed skill metadata is needed. -pub struct SkillsChangedNotification {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadNameUpdatedNotification { - pub thread_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub thread_name: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalUpdatedNotification { - pub thread_id: String, - pub turn_id: Option, - pub goal: ThreadGoal, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ThreadGoalClearedNotification { - pub thread_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnStartedNotification { - pub thread_id: String, - pub turn: Turn, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HookStartedNotification { - pub thread_id: String, - pub turn_id: Option, - pub run: HookRunSummary, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct Usage { - pub input_tokens: i32, - pub cached_input_tokens: i32, - pub output_tokens: i32, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnCompletedNotification { - pub thread_id: String, - pub turn: Turn, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct HookCompletedNotification { - pub thread_id: String, - pub turn_id: Option, - pub run: HookRunSummary, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// Notification that the turn-level unified diff has changed. -/// Contains the latest aggregated diff across all file changes in the turn. -pub struct TurnDiffUpdatedNotification { - pub thread_id: String, - pub turn_id: String, - pub diff: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnPlanUpdatedNotification { - pub thread_id: String, - pub turn_id: String, - pub explanation: Option, - pub plan: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TurnPlanStep { - pub step: String, - pub status: TurnPlanStepStatus, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum TurnPlanStepStatus { - Pending, - InProgress, - Completed, -} - -impl From for TurnPlanStep { - fn from(value: CorePlanItemArg) -> Self { - Self { - step: value.step, - status: value.status.into(), - } - } -} - -impl From for TurnPlanStepStatus { - fn from(value: CorePlanStepStatus) -> Self { - match value { - CorePlanStepStatus::Pending => Self::Pending, - CorePlanStepStatus::InProgress => Self::InProgress, - CorePlanStepStatus::Completed => Self::Completed, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ItemStartedNotification { - pub item: ThreadItem, - pub thread_id: String, - pub turn_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// [UNSTABLE] Temporary notification payload for approval auto-review. This -/// shape is expected to change soon. -pub struct ItemGuardianApprovalReviewStartedNotification { - pub thread_id: String, - pub turn_id: String, - /// Stable identifier for this review. - pub review_id: String, - /// Identifier for the reviewed item or tool call when one exists. - /// - /// In most cases, one review maps to one target item. The exceptions are - /// - execve reviews, where a single command may contain multiple execve - /// calls to review (only possible when using the shell_zsh_fork feature) - /// - network policy reviews, where there is no target item - /// - /// A network call is triggered by a CommandExecution item, so having a - /// target_item_id set to the CommandExecution item would be misleading - /// because the review is about the network call, not the command execution. - /// Therefore, target_item_id is set to None for network policy reviews. - pub target_item_id: Option, - pub review: GuardianApprovalReview, - pub action: GuardianApprovalReviewAction, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// [UNSTABLE] Temporary notification payload for approval auto-review. This -/// shape is expected to change soon. -pub struct ItemGuardianApprovalReviewCompletedNotification { - pub thread_id: String, - pub turn_id: String, - /// Stable identifier for this review. - pub review_id: String, - /// Identifier for the reviewed item or tool call when one exists. - /// - /// In most cases, one review maps to one target item. The exceptions are - /// - execve reviews, where a single command may contain multiple execve - /// calls to review (only possible when using the shell_zsh_fork feature) - /// - network policy reviews, where there is no target item - /// - /// A network call is triggered by a CommandExecution item, so having a - /// target_item_id set to the CommandExecution item would be misleading - /// because the review is about the network call, not the command execution. - /// Therefore, target_item_id is set to None for network policy reviews. - pub target_item_id: Option, - pub decision_source: AutoReviewDecisionSource, - pub review: GuardianApprovalReview, - pub action: GuardianApprovalReviewAction, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ItemCompletedNotification { - pub item: ThreadItem, - pub thread_id: String, - pub turn_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct RawResponseItemCompletedNotification { - pub thread_id: String, - pub turn_id: String, - pub item: ResponseItem, -} - -// Item-specific progress notifications -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AgentMessageDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should -/// not assume concatenated deltas match the completed plan item content. -pub struct PlanDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReasoningSummaryTextDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, - #[ts(type = "number")] - pub summary_index: i64, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReasoningSummaryPartAddedNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - #[ts(type = "number")] - pub summary_index: i64, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ReasoningTextDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, - #[ts(type = "number")] - pub content_index: i64, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TerminalInteractionNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub process_id: String, - pub stdin: String, -} - -#[serde_as] -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecutionOutputDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, -} - -/// Base64-encoded output chunk emitted for a streaming `command/exec` request. -/// -/// These notifications are connection-scoped. If the originating connection -/// closes, the server terminates the process. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecOutputDeltaNotification { - /// Client-supplied, connection-scoped `processId` from the original - /// `command/exec` request. - pub process_id: String, - /// Output stream for this chunk. - pub stream: CommandExecOutputStream, - /// Base64-encoded output bytes. - pub delta_base64: String, - /// `true` on the final streamed chunk for a stream when `outputBytesCap` - /// truncated later output on that stream. - pub cap_reached: bool, -} - -/// Deprecated legacy notification for `apply_patch` textual output. -/// -/// The server no longer emits this notification. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FileChangeOutputDeltaNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub delta: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FileChangePatchUpdatedNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub changes: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ServerRequestResolvedNotification { - pub thread_id: String, - pub request_id: RequestId, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpToolCallProgressNotification { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerOauthLoginCompletedNotification { - pub name: String, - pub success: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub error: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum McpServerStartupState { - Starting, - Ready, - Failed, - Cancelled, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerStatusUpdatedNotification { - pub name: String, - pub status: McpServerStartupState, - pub error: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct WindowsWorldWritableWarningNotification { - pub sample_paths: Vec, - pub extra_count: usize, - pub failed_scan: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum WindowsSandboxSetupMode { - Elevated, - Unelevated, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct WindowsSandboxSetupStartParams { - pub mode: WindowsSandboxSetupMode, - #[ts(optional = nullable)] - pub cwd: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct WindowsSandboxSetupStartResponse { - pub started: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct WindowsSandboxSetupCompletedNotification { - pub mode: WindowsSandboxSetupMode, - pub success: bool, - pub error: Option, -} - -/// Deprecated: Use `ContextCompaction` item type instead. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ContextCompactedNotification { - pub thread_id: String, - pub turn_id: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecutionRequestApprovalParams { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - /// Unique identifier for this specific approval callback. - /// - /// For regular shell/unified_exec approvals, this is null. - /// - /// For zsh-exec-bridge subcommand approvals, multiple callbacks can belong to - /// one parent `itemId`, so `approvalId` is a distinct opaque callback id - /// (a UUID) used to disambiguate routing. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub approval_id: Option, - /// Optional explanatory reason (e.g. request for network access). - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub reason: Option, - /// Optional context for a managed-network approval prompt. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub network_approval_context: Option, - /// The command to be executed. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub command: Option, - /// The command's working directory. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub cwd: Option, - /// Best-effort parsed command actions for friendly display. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub command_actions: Option>, - /// Optional additional permissions requested for this command. - #[experimental("item/commandExecution/requestApproval.additionalPermissions")] - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub additional_permissions: Option, - /// Optional proposed execpolicy amendment to allow similar commands without prompting. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub proposed_execpolicy_amendment: Option, - /// Optional proposed network policy amendments (allow/deny host) for future requests. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub proposed_network_policy_amendments: Option>, - /// Ordered list of decisions the client may present for this prompt. - #[experimental("item/commandExecution/requestApproval.availableDecisions")] - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional = nullable)] - pub available_decisions: Option>, -} - -impl CommandExecutionRequestApprovalParams { - pub fn strip_experimental_fields(&mut self) { - // TODO: Avoid hardcoding individual experimental fields here. - // We need a generic outbound compatibility design for stripping or - // otherwise handling experimental server->client payloads. - self.additional_permissions = None; - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CommandExecutionRequestApprovalResponse { - pub decision: CommandExecutionApprovalDecision, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct FileChangeRequestApprovalParams { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - /// Optional explanatory reason (e.g. request for extra write access). - #[ts(optional = nullable)] - pub reason: Option, - /// [UNSTABLE] When set, the agent is asking the user to allow writes under this root - /// for the remainder of the session (unclear if this is honored today). - #[ts(optional = nullable)] - pub grant_root: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[ts(export_to = "v2/")] -pub struct FileChangeRequestApprovalResponse { - pub decision: FileChangeApprovalDecision, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub enum McpServerElicitationAction { - Accept, - Decline, - Cancel, -} - -impl McpServerElicitationAction { - pub fn to_core(self) -> codex_protocol::approvals::ElicitationAction { - match self { - Self::Accept => codex_protocol::approvals::ElicitationAction::Accept, - Self::Decline => codex_protocol::approvals::ElicitationAction::Decline, - Self::Cancel => codex_protocol::approvals::ElicitationAction::Cancel, - } - } -} - -impl From for rmcp::model::ElicitationAction { - fn from(value: McpServerElicitationAction) -> Self { - match value { - McpServerElicitationAction::Accept => Self::Accept, - McpServerElicitationAction::Decline => Self::Decline, - McpServerElicitationAction::Cancel => Self::Cancel, - } - } -} - -impl From for McpServerElicitationAction { - fn from(value: rmcp::model::ElicitationAction) -> Self { - match value { - rmcp::model::ElicitationAction::Accept => Self::Accept, - rmcp::model::ElicitationAction::Decline => Self::Decline, - rmcp::model::ElicitationAction::Cancel => Self::Cancel, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerElicitationRequestParams { - pub thread_id: String, - /// Active Codex turn when this elicitation was observed, if app-server could correlate one. - /// - /// This is nullable because MCP models elicitation as a standalone server-to-client request - /// identified by the MCP server request id. It may be triggered during a turn, but turn - /// context is app-server correlation rather than part of the protocol identity of the - /// elicitation itself. - pub turn_id: Option, - pub server_name: String, - #[serde(flatten)] - pub request: McpServerElicitationRequest, - // TODO: When core can correlate an elicitation with an MCP tool call, expose the associated - // McpToolCall item id here as an optional field. The current core event does not carry that - // association. -} - -/// Typed form schema for MCP `elicitation/create` requests. -/// -/// This matches the `requestedSchema` shape from the MCP 2025-11-25 -/// `ElicitRequestFormParams` schema. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationSchema { - #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")] - #[ts(optional, rename = "$schema")] - pub schema_uri: Option, - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationObjectType, - pub properties: BTreeMap, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub required: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -pub enum McpElicitationObjectType { - Object, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(untagged)] -#[ts(export_to = "v2/")] -pub enum McpElicitationPrimitiveSchema { - Enum(McpElicitationEnumSchema), - String(McpElicitationStringSchema), - Number(McpElicitationNumberSchema), - Boolean(McpElicitationBooleanSchema), -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationStringSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationStringType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub min_length: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub max_length: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub format: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -pub enum McpElicitationStringType { - String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "kebab-case")] -#[ts(rename_all = "kebab-case", export_to = "v2/")] -pub enum McpElicitationStringFormat { - Email, - Uri, - Date, - DateTime, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationNumberSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationNumberType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub minimum: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub maximum: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -pub enum McpElicitationNumberType { - Number, - Integer, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationBooleanSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationBooleanType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -pub enum McpElicitationBooleanType { - Boolean, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(untagged)] -#[ts(export_to = "v2/")] -pub enum McpElicitationEnumSchema { - SingleSelect(McpElicitationSingleSelectEnumSchema), - MultiSelect(McpElicitationMultiSelectEnumSchema), - Legacy(McpElicitationLegacyTitledEnumSchema), -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationLegacyTitledEnumSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationStringType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(rename = "enum")] - #[ts(rename = "enum")] - pub enum_: Vec, - #[serde(rename = "enumNames", skip_serializing_if = "Option::is_none")] - #[ts(optional, rename = "enumNames")] - pub enum_names: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(untagged)] -#[ts(export_to = "v2/")] -pub enum McpElicitationSingleSelectEnumSchema { - Untitled(McpElicitationUntitledSingleSelectEnumSchema), - Titled(McpElicitationTitledSingleSelectEnumSchema), -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationUntitledSingleSelectEnumSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationStringType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(rename = "enum")] - #[ts(rename = "enum")] - pub enum_: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationTitledSingleSelectEnumSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationStringType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(rename = "oneOf")] - #[ts(rename = "oneOf")] - pub one_of: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(untagged)] -#[ts(export_to = "v2/")] -pub enum McpElicitationMultiSelectEnumSchema { - Untitled(McpElicitationUntitledMultiSelectEnumSchema), - Titled(McpElicitationTitledMultiSelectEnumSchema), -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationUntitledMultiSelectEnumSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationArrayType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub min_items: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub max_items: Option, - pub items: McpElicitationUntitledEnumItems, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationTitledMultiSelectEnumSchema { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationArrayType, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub title: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub description: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub min_items: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub max_items: Option, - pub items: McpElicitationTitledEnumItems, - #[serde(skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub default: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "lowercase")] -#[ts(export_to = "v2/")] -pub enum McpElicitationArrayType { - Array, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationUntitledEnumItems { - #[serde(rename = "type")] - #[ts(rename = "type")] - pub type_: McpElicitationStringType, - #[serde(rename = "enum")] - #[ts(rename = "enum")] - pub enum_: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationTitledEnumItems { - #[serde(rename = "anyOf", alias = "oneOf")] - #[ts(rename = "anyOf")] - pub any_of: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(deny_unknown_fields)] -#[ts(export_to = "v2/")] -pub struct McpElicitationConstOption { - #[serde(rename = "const")] - #[ts(rename = "const")] - pub const_: String, - pub title: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "mode", rename_all = "camelCase")] -#[ts(tag = "mode")] -#[ts(export_to = "v2/")] -pub enum McpServerElicitationRequest { - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Form { - #[serde(rename = "_meta")] - #[ts(rename = "_meta")] - meta: Option, - message: String, - requested_schema: McpElicitationSchema, - }, - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Url { - #[serde(rename = "_meta")] - #[ts(rename = "_meta")] - meta: Option, - message: String, - url: String, - elicitation_id: String, - }, -} - -impl TryFrom for McpServerElicitationRequest { - type Error = serde_json::Error; - - fn try_from(value: CoreElicitationRequest) -> Result { - match value { - CoreElicitationRequest::Form { - meta, - message, - requested_schema, - } => Ok(Self::Form { - meta, - message, - requested_schema: serde_json::from_value(requested_schema)?, - }), - CoreElicitationRequest::Url { - meta, - message, - url, - elicitation_id, - } => Ok(Self::Url { - meta, - message, - url, - elicitation_id, - }), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct McpServerElicitationRequestResponse { - pub action: McpServerElicitationAction, - /// Structured user input for accepted elicitations, mirroring RMCP `CreateElicitationResult`. - /// - /// This is nullable because decline/cancel responses have no content. - pub content: Option, - /// Optional client metadata for form-mode action handling. - #[serde(rename = "_meta")] - #[ts(rename = "_meta")] - pub meta: Option, -} - -impl From for rmcp::model::CreateElicitationResult { - fn from(value: McpServerElicitationRequestResponse) -> Self { - Self { - action: value.action.into(), - content: value.content, - } - } -} - -impl From for McpServerElicitationRequestResponse { - fn from(value: rmcp::model::CreateElicitationResult) -> Self { - Self { - action: value.action.into(), - content: value.content, - meta: None, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DynamicToolCallParams { - pub thread_id: String, - pub turn_id: String, - pub call_id: String, - pub namespace: Option, - pub tool: String, - pub arguments: JsonValue, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PermissionsRequestApprovalParams { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub cwd: AbsolutePathBuf, - pub reason: Option, - pub permissions: RequestPermissionProfile, -} - -v2_enum_from_core!( - #[derive(Default)] - pub enum PermissionGrantScope from CorePermissionGrantScope { - #[default] - Turn, - Session - } -); - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct PermissionsRequestApprovalResponse { - pub permissions: GrantedPermissionProfile, - #[serde(default)] - pub scope: PermissionGrantScope, - /// Review every subsequent command in this turn before normal sandboxed execution. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub strict_auto_review: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DynamicToolCallResponse { - pub content_items: Vec, - pub success: bool, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum DynamicToolCallOutputContentItem { - #[serde(rename_all = "camelCase")] - InputText { text: String }, - #[serde(rename_all = "camelCase")] - InputImage { image_url: String }, -} - -impl From - for codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem -{ - fn from(item: DynamicToolCallOutputContentItem) -> Self { - match item { - DynamicToolCallOutputContentItem::InputText { text } => Self::InputText { text }, - DynamicToolCallOutputContentItem::InputImage { image_url } => { - Self::InputImage { image_url } - } - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL. Defines a single selectable option for request_user_input. -pub struct ToolRequestUserInputOption { - pub label: String, - pub description: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL. Represents one request_user_input question and its required options. -pub struct ToolRequestUserInputQuestion { - pub id: String, - pub header: String, - pub question: String, - #[serde(default)] - pub is_other: bool, - #[serde(default)] - pub is_secret: bool, - pub options: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL. Params sent with a request_user_input event. -pub struct ToolRequestUserInputParams { - pub thread_id: String, - pub turn_id: String, - pub item_id: String, - pub questions: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL. Captures a user's answer to a request_user_input question. -pub struct ToolRequestUserInputAnswer { - pub answers: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -/// EXPERIMENTAL. Response payload mapping question ids to answers. -pub struct ToolRequestUserInputResponse { - pub answers: HashMap, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AccountRateLimitsUpdatedNotification { - pub rate_limits: RateLimitSnapshot, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct RateLimitSnapshot { - pub limit_id: Option, - pub limit_name: Option, - pub primary: Option, - pub secondary: Option, - pub credits: Option, - pub plan_type: Option, - pub rate_limit_reached_type: Option, -} - -impl From for RateLimitSnapshot { - fn from(value: CoreRateLimitSnapshot) -> Self { - Self { - limit_id: value.limit_id, - limit_name: value.limit_name, - primary: value.primary.map(RateLimitWindow::from), - secondary: value.secondary.map(RateLimitWindow::from), - credits: value.credits.map(CreditsSnapshot::from), - plan_type: value.plan_type, - rate_limit_reached_type: value - .rate_limit_reached_type - .map(RateLimitReachedType::from), - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "snake_case")] -#[ts(export_to = "v2/", rename_all = "snake_case")] -pub enum RateLimitReachedType { - RateLimitReached, - WorkspaceOwnerCreditsDepleted, - WorkspaceMemberCreditsDepleted, - WorkspaceOwnerUsageLimitReached, - WorkspaceMemberUsageLimitReached, -} - -impl From for RateLimitReachedType { - fn from(value: CoreRateLimitReachedType) -> Self { - match value { - CoreRateLimitReachedType::RateLimitReached => Self::RateLimitReached, - CoreRateLimitReachedType::WorkspaceOwnerCreditsDepleted => { - Self::WorkspaceOwnerCreditsDepleted - } - CoreRateLimitReachedType::WorkspaceMemberCreditsDepleted => { - Self::WorkspaceMemberCreditsDepleted - } - CoreRateLimitReachedType::WorkspaceOwnerUsageLimitReached => { - Self::WorkspaceOwnerUsageLimitReached - } - CoreRateLimitReachedType::WorkspaceMemberUsageLimitReached => { - Self::WorkspaceMemberUsageLimitReached - } - } - } -} - -impl From for CoreRateLimitReachedType { - fn from(value: RateLimitReachedType) -> Self { - match value { - RateLimitReachedType::RateLimitReached => Self::RateLimitReached, - RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { - Self::WorkspaceOwnerCreditsDepleted - } - RateLimitReachedType::WorkspaceMemberCreditsDepleted => { - Self::WorkspaceMemberCreditsDepleted - } - RateLimitReachedType::WorkspaceOwnerUsageLimitReached => { - Self::WorkspaceOwnerUsageLimitReached - } - RateLimitReachedType::WorkspaceMemberUsageLimitReached => { - Self::WorkspaceMemberUsageLimitReached - } - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct RateLimitWindow { - pub used_percent: i32, - #[ts(type = "number | null")] - pub window_duration_mins: Option, - #[ts(type = "number | null")] - pub resets_at: Option, -} - -impl From for RateLimitWindow { - fn from(value: CoreRateLimitWindow) -> Self { - Self { - used_percent: value.used_percent.round() as i32, - window_duration_mins: value.window_minutes, - resets_at: value.resets_at, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct CreditsSnapshot { - pub has_credits: bool, - pub unlimited: bool, - pub balance: Option, -} - -impl From for CreditsSnapshot { - fn from(value: CoreCreditsSnapshot) -> Self { - Self { - has_credits: value.has_credits, - unlimited: value.unlimited, - balance: value.balance, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct AccountLoginCompletedNotification { - // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. - // Convert to/from UUIDs at the application layer as needed. - pub login_id: Option, - pub success: bool, - pub error: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelReroutedNotification { - pub thread_id: String, - pub turn_id: String, - pub from_model: String, - pub to_model: String, - pub reason: ModelRerouteReason, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ModelVerificationNotification { - pub thread_id: String, - pub turn_id: String, - pub verifications: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct DeprecationNoticeNotification { - /// Concise summary of what is deprecated. - pub summary: String, - /// Optional extra guidance, such as migration steps or rationale. - pub details: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct WarningNotification { - /// Optional thread target when the warning applies to a specific thread. - pub thread_id: Option, - /// Concise warning message for the user. - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct GuardianWarningNotification { - /// Thread target for the guardian warning. - pub thread_id: String, - /// Concise guardian warning message for the user. - pub message: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TextPosition { - /// 1-based line number. - pub line: usize, - /// 1-based column number (in Unicode scalar values). - pub column: usize, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct TextRange { - pub start: TextPosition, - pub end: TextPosition, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] -#[serde(rename_all = "camelCase")] -#[ts(export_to = "v2/")] -pub struct ConfigWarningNotification { - /// Concise summary of the warning. - pub summary: String, - /// Optional extra guidance or error details. - pub details: Option, - /// Optional path to the config file that triggered the warning. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub path: Option, - /// Optional range for the error location inside the config file. - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub range: Option, -} - -#[cfg(test)] -mod tests { - use super::*; - use codex_protocol::items::AgentMessageContent; - use codex_protocol::items::AgentMessageItem; - use codex_protocol::items::FileChangeItem; - use codex_protocol::items::ImageViewItem; - use codex_protocol::items::ReasoningItem; - use codex_protocol::items::TurnItem; - use codex_protocol::items::UserMessageItem; - use codex_protocol::items::WebSearchItem; - use codex_protocol::models::WebSearchAction as CoreWebSearchAction; - use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; - use codex_protocol::user_input::UserInput as CoreUserInput; - use codex_utils_absolute_path::test_support::PathBufExt; - use codex_utils_absolute_path::test_support::test_path_buf; - use pretty_assertions::assert_eq; - use serde_json::json; - use std::num::NonZeroUsize; - use std::path::PathBuf; - - fn absolute_path_string(path: &str) -> String { - let path = format!("/{}", path.trim_start_matches('/')); - test_path_buf(&path).display().to_string() - } - - fn absolute_path(path: &str) -> AbsolutePathBuf { - let path = format!("/{}", path.trim_start_matches('/')); - test_path_buf(&path).abs() - } - - fn test_absolute_path() -> AbsolutePathBuf { - absolute_path("readable") - } - - #[test] - fn approvals_reviewer_serializes_auto_review_and_accepts_legacy_guardian_subagent() { - assert_eq!( - serde_json::to_string(&ApprovalsReviewer::User).expect("serialize reviewer"), - "\"user\"" - ); - assert_eq!( - serde_json::to_string(&ApprovalsReviewer::AutoReview).expect("serialize reviewer"), - "\"guardian_subagent\"" - ); - - for value in ["user", "auto_review", "guardian_subagent"] { - let json = format!("\"{value}\""); - let reviewer: ApprovalsReviewer = - serde_json::from_str(&json).expect("deserialize reviewer"); - let expected = if value == "user" { - ApprovalsReviewer::User - } else { - ApprovalsReviewer::AutoReview - }; - assert_eq!(expected, reviewer); - } - } - - #[test] - fn thread_list_params_accepts_single_cwd() { - let params = serde_json::from_value::(json!({ - "cwd": "/workspace", - })) - .expect("single cwd should deserialize"); - - assert_eq!( - params.cwd, - Some(ThreadListCwdFilter::One("/workspace".to_string())) - ); - assert!(!params.use_state_db_only); - } - - #[test] - fn thread_list_params_accepts_multiple_cwds() { - let params = serde_json::from_value::(json!({ - "cwd": ["/workspace", "/other-workspace"], - })) - .expect("cwd array should deserialize"); - - assert_eq!( - params.cwd, - Some(ThreadListCwdFilter::Many(vec![ - "/workspace".to_string(), - "/other-workspace".to_string(), - ])) - ); - } - - #[test] - fn thread_list_params_accepts_state_db_only_flag() { - let params = serde_json::from_value::(json!({ - "useStateDbOnly": true, - })) - .expect("state db only flag should deserialize"); - - assert!(params.use_state_db_only); - } - - #[test] - fn collab_agent_state_maps_interrupted_status() { - assert_eq!( - CollabAgentState::from(CoreAgentStatus::Interrupted), - CollabAgentState { - status: CollabAgentStatus::Interrupted, - message: None, - } - ); - } - - #[test] - fn external_agent_config_plugins_details_round_trip() { - let item: ExternalAgentConfigMigrationItem = serde_json::from_value(json!({ - "itemType": "PLUGINS", - "description": "Install supported plugins from Claude settings", - "cwd": absolute_path_string("repo"), - "details": { - "plugins": [ - { - "marketplaceName": "team-marketplace", - "pluginNames": ["asana"] - } - ] - } - })) - .expect("plugins migration item should deserialize"); - - assert_eq!( - item, - ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::Plugins, - description: "Install supported plugins from Claude settings".to_string(), - cwd: Some(PathBuf::from(absolute_path_string("repo"))), - details: Some(MigrationDetails { - plugins: vec![PluginsMigration { - marketplace_name: "team-marketplace".to_string(), - plugin_names: vec!["asana".to_string()], - }], - ..Default::default() - }), - } - ); - } - - #[test] - fn external_agent_config_import_params_accept_legacy_plugin_details() { - let params: ExternalAgentConfigImportParams = serde_json::from_value(json!({ - "migrationItems": [{ - "itemType": "PLUGINS", - "description": "Install supported plugins from Claude settings", - "cwd": absolute_path_string("repo"), - "details": { - "plugins": [ - { - "marketplaceName": "team-marketplace", - "pluginNames": ["asana"] - } - ] - } - }] - })) - .expect("legacy plugin import params should deserialize"); - - assert_eq!( - params, - ExternalAgentConfigImportParams { - migration_items: vec![ExternalAgentConfigMigrationItem { - item_type: ExternalAgentConfigMigrationItemType::Plugins, - description: "Install supported plugins from Claude settings".to_string(), - cwd: Some(PathBuf::from(absolute_path_string("repo"))), - details: Some(MigrationDetails { - plugins: vec![PluginsMigration { - marketplace_name: "team-marketplace".to_string(), - plugin_names: vec!["asana".to_string()], - }], - ..Default::default() - }), - }], - } - ); - } - - #[test] - fn command_execution_request_approval_rejects_relative_additional_permission_paths() { - let err = serde_json::from_value::(json!({ - "threadId": "thr_123", - "turnId": "turn_123", - "itemId": "call_123", - "command": "cat file", - "cwd": absolute_path_string("tmp"), - "commandActions": null, - "reason": null, - "networkApprovalContext": null, - "additionalPermissions": { - "network": null, - "fileSystem": { - "read": ["relative/path"], - "write": null - } - }, - "proposedExecpolicyAmendment": null, - "proposedNetworkPolicyAmendments": null, - "availableDecisions": null - })) - .expect_err("relative additional permission paths should fail"); - assert!( - err.to_string() - .contains("AbsolutePathBuf deserialized without a base path"), - "unexpected error: {err}" - ); - } - - #[test] - fn permissions_request_approval_uses_request_permission_profile() { - let read_only_path = if cfg!(windows) { - r"C:\tmp\read-only" - } else { - "/tmp/read-only" - }; - let read_write_path = if cfg!(windows) { - r"C:\tmp\read-write" - } else { - "/tmp/read-write" - }; - let params = serde_json::from_value::(json!({ - "threadId": "thr_123", - "turnId": "turn_123", - "itemId": "call_123", - "cwd": absolute_path_string("repo"), - "reason": "Select a workspace root", - "permissions": { - "network": { - "enabled": true, - }, - "fileSystem": { - "read": [read_only_path], - "write": [read_write_path], - }, - }, - })) - .expect("permissions request should deserialize"); - - assert_eq!(params.cwd, absolute_path("repo")); - assert_eq!( - params.permissions, - RequestPermissionProfile { - network: Some(AdditionalNetworkPermissions { - enabled: Some(true), - }), - file_system: Some(AdditionalFileSystemPermissions { - read: Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) - .expect("path must be absolute"), - ]), - write: Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) - .expect("path must be absolute"), - ]), - glob_scan_max_depth: None, - entries: None, - }), - } - ); - - assert_eq!( - CoreRequestPermissionProfile::from(params.permissions), - CoreRequestPermissionProfile { - network: Some(CoreNetworkPermissions { - enabled: Some(true), - }), - file_system: Some(CoreFileSystemPermissions::from_read_write_roots( - Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) - .expect("path must be absolute"), - ]), - Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) - .expect("path must be absolute"), - ]), - )), - } - ); - } - - #[test] - fn permissions_request_approval_rejects_macos_permissions() { - let err = serde_json::from_value::(json!({ - "threadId": "thr_123", - "turnId": "turn_123", - "itemId": "call_123", - "cwd": absolute_path_string("repo"), - "reason": "Select a workspace root", - "permissions": { - "network": null, - "fileSystem": null, - "macos": { - "preferences": "read_only", - "automations": "none", - "launchServices": false, - "accessibility": false, - "calendar": false, - "reminders": false, - "contacts": "none", - }, - }, - })) - .expect_err("permissions request should reject macos permissions"); - - assert!( - err.to_string().contains("unknown field `macos`"), - "unexpected error: {err}" - ); - } - - #[test] - fn additional_file_system_permissions_preserves_canonical_entries() { - let core_permissions = CoreFileSystemPermissions { - entries: vec![ - CoreFileSystemSandboxEntry { - path: CoreFileSystemPath::Special { - value: CoreFileSystemSpecialPath::Root, - }, - access: CoreFileSystemAccessMode::Write, - }, - CoreFileSystemSandboxEntry { - path: CoreFileSystemPath::GlobPattern { - pattern: "**/*.env".to_string(), - }, - access: CoreFileSystemAccessMode::None, - }, - ], - glob_scan_max_depth: NonZeroUsize::new(2), - }; - - let permissions = AdditionalFileSystemPermissions::from(core_permissions.clone()); - assert_eq!( - permissions, - AdditionalFileSystemPermissions { - read: None, - write: None, - glob_scan_max_depth: NonZeroUsize::new(2), - entries: Some(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Write, - }, - FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { - pattern: "**/*.env".to_string(), - }, - access: FileSystemAccessMode::None, - }, - ]), - } - ); - assert_eq!( - CoreFileSystemPermissions::from(permissions), - core_permissions - ); - } - - #[test] - fn additional_file_system_permissions_populates_entries_for_legacy_roots() { - let read_only_path = absolute_path("read-only"); - let read_write_path = absolute_path("read-write"); - let core_permissions = CoreFileSystemPermissions::from_read_write_roots( - Some(vec![read_only_path.clone()]), - Some(vec![read_write_path.clone()]), - ); - - let permissions = AdditionalFileSystemPermissions::from(core_permissions.clone()); - - assert_eq!( - permissions, - AdditionalFileSystemPermissions { - read: Some(vec![read_only_path.clone()]), - write: Some(vec![read_write_path.clone()]), - glob_scan_max_depth: None, - entries: Some(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: read_only_path, - }, - access: FileSystemAccessMode::Read, - }, - FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: read_write_path, - }, - access: FileSystemAccessMode::Write, - }, - ]), - } - ); - assert_eq!( - CoreFileSystemPermissions::from(permissions), - core_permissions - ); - } - - #[test] - fn additional_file_system_permissions_rejects_zero_glob_scan_depth() { - serde_json::from_value::(json!({ - "read": null, - "write": null, - "globScanMaxDepth": 0, - "entries": [], - })) - .expect_err("zero glob scan depth should fail deserialization"); - } - - #[test] - fn permission_profile_file_system_permissions_preserves_glob_scan_depth() { - let core_permissions = CoreManagedFileSystemPermissions::Restricted { - entries: vec![CoreFileSystemSandboxEntry { - path: CoreFileSystemPath::GlobPattern { - pattern: "**/*.env".to_string(), - }, - access: CoreFileSystemAccessMode::None, - }], - glob_scan_max_depth: NonZeroUsize::new(2), - }; - - let permissions = PermissionProfileFileSystemPermissions::from(core_permissions.clone()); - - assert_eq!( - permissions, - PermissionProfileFileSystemPermissions::Restricted { - entries: vec![FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { - pattern: "**/*.env".to_string(), - }, - access: FileSystemAccessMode::None, - }], - glob_scan_max_depth: NonZeroUsize::new(2), - } - ); - assert_eq!( - CoreManagedFileSystemPermissions::from(permissions), - core_permissions - ); - } - - #[test] - fn permission_profile_file_system_permissions_rejects_zero_glob_scan_depth() { - serde_json::from_value::(json!({ - "type": "restricted", - "entries": [], - "globScanMaxDepth": 0, - })) - .expect_err("zero glob scan depth should fail deserialization"); - } - - #[test] - fn legacy_current_working_directory_special_path_deserializes_as_project_roots() { - let special_path = serde_json::from_value::(json!({ - "kind": "current_working_directory", - })) - .expect("legacy cwd special path should deserialize"); - - assert_eq!( - special_path, - FileSystemSpecialPath::ProjectRoots { subpath: None } - ); - assert_eq!( - serde_json::to_value(&special_path).expect("serialize special path"), - json!({ - "kind": "project_roots", - "subpath": null, - }) - ); - } - - #[test] - fn permissions_request_approval_response_uses_granted_permission_profile_without_macos() { - let read_only_path = if cfg!(windows) { - r"C:\tmp\read-only" - } else { - "/tmp/read-only" - }; - let read_write_path = if cfg!(windows) { - r"C:\tmp\read-write" - } else { - "/tmp/read-write" - }; - let response = serde_json::from_value::(json!({ - "permissions": { - "network": { - "enabled": true, - }, - "fileSystem": { - "read": [read_only_path], - "write": [read_write_path], - }, - }, - })) - .expect("permissions response should deserialize"); - - assert_eq!( - response.permissions, - GrantedPermissionProfile { - network: Some(AdditionalNetworkPermissions { - enabled: Some(true), - }), - file_system: Some(AdditionalFileSystemPermissions { - read: Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) - .expect("path must be absolute"), - ]), - write: Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) - .expect("path must be absolute"), - ]), - glob_scan_max_depth: None, - entries: None, - }), - } - ); - - assert_eq!( - CoreAdditionalPermissionProfile::from(response.permissions), - CoreAdditionalPermissionProfile { - network: Some(CoreNetworkPermissions { - enabled: Some(true), - }), - file_system: Some(CoreFileSystemPermissions::from_read_write_roots( - Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) - .expect("path must be absolute"), - ]), - Some(vec![ - AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) - .expect("path must be absolute"), - ]), - )), - } - ); - } - - #[test] - fn permissions_request_approval_response_defaults_scope_to_turn() { - let response = serde_json::from_value::(json!({ - "permissions": {}, - })) - .expect("response should deserialize"); - - assert_eq!(response.scope, PermissionGrantScope::Turn); - assert_eq!(response.strict_auto_review, None); - } - - #[test] - fn permissions_request_approval_response_accepts_strict_auto_review() { - let response = serde_json::from_value::(json!({ - "permissions": {}, - "strictAutoReview": true, - })) - .expect("response should deserialize"); - - assert_eq!(response.strict_auto_review, Some(true)); - } - - #[test] - fn fs_get_metadata_response_round_trips_minimal_fields() { - let response = FsGetMetadataResponse { - is_directory: false, - is_file: true, - is_symlink: false, - created_at_ms: 123, - modified_at_ms: 456, - }; - - let value = serde_json::to_value(&response).expect("serialize fs/getMetadata response"); - assert_eq!( - value, - json!({ - "isDirectory": false, - "isFile": true, - "isSymlink": false, - "createdAtMs": 123, - "modifiedAtMs": 456, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize fs/getMetadata response"); - assert_eq!(decoded, response); - } - - #[test] - fn fs_read_file_response_round_trips_base64_data() { - let response = FsReadFileResponse { - data_base64: "aGVsbG8=".to_string(), - }; - - let value = serde_json::to_value(&response).expect("serialize fs/readFile response"); - assert_eq!( - value, - json!({ - "dataBase64": "aGVsbG8=", - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize fs/readFile response"); - assert_eq!(decoded, response); - } - - #[test] - fn fs_read_file_params_round_trip() { - let params = FsReadFileParams { - path: absolute_path("tmp/example.txt"), - }; - - let value = serde_json::to_value(¶ms).expect("serialize fs/readFile params"); - assert_eq!( - value, - json!({ - "path": absolute_path_string("tmp/example.txt"), - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize fs/readFile params"); - assert_eq!(decoded, params); - } - - #[test] - fn device_key_create_params_round_trip_uses_protection_policy() { - let params = DeviceKeyCreateParams { - protection_policy: None, - account_user_id: "account-user-1".to_string(), - client_id: "cli_123".to_string(), - }; - - let value = serde_json::to_value(¶ms).expect("serialize device/key/create params"); - assert_eq!( - value, - json!({ - "accountUserId": "account-user-1", - "clientId": "cli_123", - "protectionPolicy": null, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize device/key/create params"); - assert_eq!(decoded, params); - - let params = DeviceKeyCreateParams { - protection_policy: Some(DeviceKeyProtectionPolicy::AllowOsProtectedNonextractable), - account_user_id: "account-user-1".to_string(), - client_id: "cli_123".to_string(), - }; - let value = serde_json::to_value(¶ms) - .expect("serialize device/key/create params with protection policy"); - assert_eq!( - value, - json!({ - "accountUserId": "account-user-1", - "clientId": "cli_123", - "protectionPolicy": "allow_os_protected_nonextractable", - }) - ); - } - - #[test] - fn device_key_create_response_round_trips_protection_class() { - let response = DeviceKeyCreateResponse { - key_id: "dk_123".to_string(), - public_key_spki_der_base64: "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE".to_string(), - algorithm: DeviceKeyAlgorithm::EcdsaP256Sha256, - protection_class: DeviceKeyProtectionClass::OsProtectedNonextractable, - }; - - let value = serde_json::to_value(&response).expect("serialize device/key/create response"); - assert_eq!( - value, - json!({ - "keyId": "dk_123", - "publicKeySpkiDerBase64": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE", - "algorithm": "ecdsa_p256_sha256", - "protectionClass": "os_protected_nonextractable", - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize device/key/create response"); - assert_eq!(decoded, response); - } - - #[test] - fn device_key_sign_params_round_trip_uses_accepted_payload_enum() { - let params = DeviceKeySignParams { - key_id: "dk_123".to_string(), - payload: DeviceKeySignPayload::RemoteControlClientConnection { - nonce: "nonce-1".to_string(), - audience: RemoteControlClientConnectionAudience::RemoteControlClientWebsocket, - session_id: "wssess_123".to_string(), - target_origin: "https://chatgpt.com".to_string(), - target_path: "/api/codex/remote/control/client".to_string(), - account_user_id: "account-user-1".to_string(), - client_id: "cli_123".to_string(), - token_sha256_base64url: "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU".to_string(), - token_expires_at: 1_700_000_000, - scopes: vec!["remote_control_controller_websocket".to_string()], - }, - }; - - let value = serde_json::to_value(¶ms).expect("serialize device/key/sign params"); - assert_eq!( - value, - json!({ - "keyId": "dk_123", - "payload": { - "type": "remoteControlClientConnection", - "nonce": "nonce-1", - "audience": "remote_control_client_websocket", - "sessionId": "wssess_123", - "targetOrigin": "https://chatgpt.com", - "targetPath": "/api/codex/remote/control/client", - "accountUserId": "account-user-1", - "clientId": "cli_123", - "tokenSha256Base64url": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU", - "tokenExpiresAt": 1_700_000_000, - "scopes": ["remote_control_controller_websocket"], - }, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize device/key/sign params"); - assert_eq!(decoded, params); - } - - #[test] - fn device_key_sign_params_round_trip_uses_enrollment_payload() { - let params = DeviceKeySignParams { - key_id: "dk_123".to_string(), - payload: DeviceKeySignPayload::RemoteControlClientEnrollment { - nonce: "nonce-1".to_string(), - audience: RemoteControlClientEnrollmentAudience::RemoteControlClientEnrollment, - challenge_id: "rch_123".to_string(), - target_origin: "https://chatgpt.com".to_string(), - target_path: "/wham/remote/control/client/enroll".to_string(), - account_user_id: "account-user-1".to_string(), - client_id: "cli_123".to_string(), - device_identity_sha256_base64url: "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU" - .to_string(), - challenge_expires_at: 1_700_000_000, - }, - }; - - let value = serde_json::to_value(¶ms) - .expect("serialize device/key/sign params with enrollment payload"); - assert_eq!( - value, - json!({ - "keyId": "dk_123", - "payload": { - "type": "remoteControlClientEnrollment", - "nonce": "nonce-1", - "audience": "remote_control_client_enrollment", - "challengeId": "rch_123", - "targetOrigin": "https://chatgpt.com", - "targetPath": "/wham/remote/control/client/enroll", - "accountUserId": "account-user-1", - "clientId": "cli_123", - "deviceIdentitySha256Base64url": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU", - "challengeExpiresAt": 1_700_000_000, - }, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize device/key/sign params with enrollment payload"); - assert_eq!(decoded, params); - } - - #[test] - fn device_key_sign_response_returns_signed_payload_bytes() { - let response = DeviceKeySignResponse { - signature_der_base64: "MEUCIQD".to_string(), - signed_payload_base64: "eyJkb21haW4iOiJjb2RleA".to_string(), - algorithm: DeviceKeyAlgorithm::EcdsaP256Sha256, - }; - - let value = serde_json::to_value(&response).expect("serialize device/key/sign response"); - assert_eq!( - value, - json!({ - "signatureDerBase64": "MEUCIQD", - "signedPayloadBase64": "eyJkb21haW4iOiJjb2RleA", - "algorithm": "ecdsa_p256_sha256", - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize device/key/sign response"); - assert_eq!(decoded, response); - } - - #[test] - fn fs_create_directory_params_round_trip_with_default_recursive() { - let params = FsCreateDirectoryParams { - path: absolute_path("tmp/example"), - recursive: None, - }; - - let value = serde_json::to_value(¶ms).expect("serialize fs/createDirectory params"); - assert_eq!( - value, - json!({ - "path": absolute_path_string("tmp/example"), - "recursive": null, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize fs/createDirectory params"); - assert_eq!(decoded, params); - } - - #[test] - fn fs_write_file_params_round_trip_with_base64_data() { - let params = FsWriteFileParams { - path: absolute_path("tmp/example.bin"), - data_base64: "AAE=".to_string(), - }; - - let value = serde_json::to_value(¶ms).expect("serialize fs/writeFile params"); - assert_eq!( - value, - json!({ - "path": absolute_path_string("tmp/example.bin"), - "dataBase64": "AAE=", - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize fs/writeFile params"); - assert_eq!(decoded, params); - } - - #[test] - fn fs_copy_params_round_trip_with_recursive_directory_copy() { - let params = FsCopyParams { - source_path: absolute_path("tmp/source"), - destination_path: absolute_path("tmp/destination"), - recursive: true, - }; - - let value = serde_json::to_value(¶ms).expect("serialize fs/copy params"); - assert_eq!( - value, - json!({ - "sourcePath": absolute_path_string("tmp/source"), - "destinationPath": absolute_path_string("tmp/destination"), - "recursive": true, - }) - ); - - let decoded = - serde_json::from_value::(value).expect("deserialize fs/copy params"); - assert_eq!(decoded, params); - } - - #[test] - fn thread_shell_command_params_round_trip() { - let params = ThreadShellCommandParams { - thread_id: "thr_123".to_string(), - command: "printf 'hello world\\n'".to_string(), - }; - - let value = serde_json::to_value(¶ms).expect("serialize thread/shellCommand params"); - assert_eq!( - value, - json!({ - "threadId": "thr_123", - "command": "printf 'hello world\\n'", - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize thread/shellCommand params"); - assert_eq!(decoded, params); - } - - #[test] - fn thread_shell_command_response_round_trip() { - let response = ThreadShellCommandResponse {}; - - let value = - serde_json::to_value(&response).expect("serialize thread/shellCommand response"); - assert_eq!(value, json!({})); - - let decoded = serde_json::from_value::(value) - .expect("deserialize thread/shellCommand response"); - assert_eq!(decoded, response); - } - - #[test] - fn fs_changed_notification_round_trips() { - let notification = FsChangedNotification { - watch_id: "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1".to_string(), - changed_paths: vec![ - absolute_path("tmp/repo/.git/HEAD"), - absolute_path("tmp/repo/.git/FETCH_HEAD"), - ], - }; - - let value = serde_json::to_value(¬ification).expect("serialize fs/changed notification"); - assert_eq!( - value, - json!({ - "watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1", - "changedPaths": [ - absolute_path_string("tmp/repo/.git/HEAD"), - absolute_path_string("tmp/repo/.git/FETCH_HEAD"), - ], - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize fs/changed notification"); - assert_eq!(decoded, notification); - } - - #[test] - fn command_exec_params_default_optional_streaming_flags() { - let params = serde_json::from_value::(json!({ - "command": ["ls", "-la"], - "timeoutMs": 1000, - "cwd": "/tmp" - })) - .expect("command/exec payload should deserialize"); - - assert_eq!( - params, - CommandExecParams { - command: vec!["ls".to_string(), "-la".to_string()], - process_id: None, - tty: false, - stream_stdin: false, - stream_stdout_stderr: false, - output_bytes_cap: None, - disable_output_cap: false, - disable_timeout: false, - timeout_ms: Some(1000), - cwd: Some(PathBuf::from("/tmp")), - env: None, - size: None, - sandbox_policy: None, - permission_profile: None, - } - ); - } - - #[test] - fn command_exec_params_round_trips_disable_timeout() { - let params = CommandExecParams { - command: vec!["sleep".to_string(), "30".to_string()], - process_id: Some("sleep-1".to_string()), - tty: false, - stream_stdin: false, - stream_stdout_stderr: false, - output_bytes_cap: None, - disable_output_cap: false, - disable_timeout: true, - timeout_ms: None, - cwd: None, - env: None, - size: None, - sandbox_policy: None, - permission_profile: None, - }; - - let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); - assert_eq!( - value, - json!({ - "command": ["sleep", "30"], - "processId": "sleep-1", - "disableTimeout": true, - "timeoutMs": null, - "cwd": null, - "env": null, - "size": null, - "sandboxPolicy": null, - "permissionProfile": null, - "outputBytesCap": null, - }) - ); - - let decoded = - serde_json::from_value::(value).expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn command_exec_params_round_trips_disable_output_cap() { - let params = CommandExecParams { - command: vec!["yes".to_string()], - process_id: Some("yes-1".to_string()), - tty: false, - stream_stdin: false, - stream_stdout_stderr: true, - output_bytes_cap: None, - disable_output_cap: true, - disable_timeout: false, - timeout_ms: None, - cwd: None, - env: None, - size: None, - sandbox_policy: None, - permission_profile: None, - }; - - let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); - assert_eq!( - value, - json!({ - "command": ["yes"], - "processId": "yes-1", - "streamStdoutStderr": true, - "outputBytesCap": null, - "disableOutputCap": true, - "timeoutMs": null, - "cwd": null, - "env": null, - "size": null, - "sandboxPolicy": null, - "permissionProfile": null, - }) - ); - - let decoded = - serde_json::from_value::(value).expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn command_exec_params_round_trips_env_overrides_and_unsets() { - let params = CommandExecParams { - command: vec!["printenv".to_string(), "FOO".to_string()], - process_id: Some("env-1".to_string()), - tty: false, - stream_stdin: false, - stream_stdout_stderr: false, - output_bytes_cap: None, - disable_output_cap: false, - disable_timeout: false, - timeout_ms: None, - cwd: None, - env: Some(HashMap::from([ - ("FOO".to_string(), Some("override".to_string())), - ("BAR".to_string(), Some("added".to_string())), - ("BAZ".to_string(), None), - ])), - size: None, - sandbox_policy: None, - permission_profile: None, - }; - - let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); - assert_eq!( - value, - json!({ - "command": ["printenv", "FOO"], - "processId": "env-1", - "outputBytesCap": null, - "timeoutMs": null, - "cwd": null, - "env": { - "FOO": "override", - "BAR": "added", - "BAZ": null, - }, - "size": null, - "sandboxPolicy": null, - "permissionProfile": null, - }) - ); - - let decoded = - serde_json::from_value::(value).expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn command_exec_write_round_trips_close_only_payload() { - let params = CommandExecWriteParams { - process_id: "proc-7".to_string(), - delta_base64: None, - close_stdin: true, - }; - - let value = serde_json::to_value(¶ms).expect("serialize command/exec/write params"); - assert_eq!( - value, - json!({ - "processId": "proc-7", - "deltaBase64": null, - "closeStdin": true, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn command_exec_terminate_round_trips() { - let params = CommandExecTerminateParams { - process_id: "proc-8".to_string(), - }; - - let value = serde_json::to_value(¶ms).expect("serialize command/exec/terminate params"); - assert_eq!( - value, - json!({ - "processId": "proc-8", - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn command_exec_params_round_trip_with_size() { - let params = CommandExecParams { - command: vec!["top".to_string()], - process_id: Some("pty-1".to_string()), - tty: true, - stream_stdin: false, - stream_stdout_stderr: false, - output_bytes_cap: None, - disable_output_cap: false, - disable_timeout: false, - timeout_ms: None, - cwd: None, - env: None, - size: Some(CommandExecTerminalSize { - rows: 40, - cols: 120, - }), - sandbox_policy: None, - permission_profile: None, - }; - - let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); - assert_eq!( - value, - json!({ - "command": ["top"], - "processId": "pty-1", - "tty": true, - "outputBytesCap": null, - "timeoutMs": null, - "cwd": null, - "env": null, - "size": { - "rows": 40, - "cols": 120, - }, - "sandboxPolicy": null, - "permissionProfile": null, - }) - ); - - let decoded = - serde_json::from_value::(value).expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn command_exec_resize_round_trips() { - let params = CommandExecResizeParams { - process_id: "proc-9".to_string(), - size: CommandExecTerminalSize { - rows: 50, - cols: 160, - }, - }; - - let value = serde_json::to_value(¶ms).expect("serialize command/exec/resize params"); - assert_eq!( - value, - json!({ - "processId": "proc-9", - "size": { - "rows": 50, - "cols": 160, - }, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize round-trip"); - assert_eq!(decoded, params); - } - - #[test] - fn command_exec_output_delta_round_trips() { - let notification = CommandExecOutputDeltaNotification { - process_id: "proc-1".to_string(), - stream: CommandExecOutputStream::Stdout, - delta_base64: "AQI=".to_string(), - cap_reached: false, - }; - - let value = serde_json::to_value(¬ification) - .expect("serialize command/exec/outputDelta notification"); - assert_eq!( - value, - json!({ - "processId": "proc-1", - "stream": "stdout", - "deltaBase64": "AQI=", - "capReached": false, - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize round-trip"); - assert_eq!(decoded, notification); - } - - #[test] - fn command_execution_output_delta_round_trips() { - let notification = CommandExecutionOutputDeltaNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - item_id: "item-1".to_string(), - delta: "\u{fffd}a\n".to_string(), - }; - - let value = serde_json::to_value(¬ification) - .expect("serialize item/commandExecution/outputDelta notification"); - assert_eq!( - value, - json!({ - "threadId": "thread-1", - "turnId": "turn-1", - "itemId": "item-1", - "delta": "\u{fffd}a\n", - }) - ); - - let decoded = serde_json::from_value::(value) - .expect("deserialize round-trip"); - assert_eq!(decoded, notification); - } - - #[test] - fn sandbox_policy_round_trips_external_sandbox_network_access() { - let v2_policy = SandboxPolicy::ExternalSandbox { - network_access: NetworkAccess::Enabled, - }; - - let core_policy = v2_policy.to_core(); - assert_eq!( - core_policy, - codex_protocol::protocol::SandboxPolicy::ExternalSandbox { - network_access: CoreNetworkAccess::Enabled, - } - ); - - let back_to_v2 = SandboxPolicy::from(core_policy); - assert_eq!(back_to_v2, v2_policy); - } - - #[test] - fn sandbox_policy_round_trips_read_only_network_access() { - let v2_policy = SandboxPolicy::ReadOnly { - network_access: true, - }; - - let core_policy = v2_policy.to_core(); - assert_eq!( - core_policy, - codex_protocol::protocol::SandboxPolicy::ReadOnly { - network_access: true, - } - ); - - let back_to_v2 = SandboxPolicy::from(core_policy); - assert_eq!(back_to_v2, v2_policy); - } - - #[test] - fn ask_for_approval_granular_round_trips_request_permissions_flag() { - let v2_policy = AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: true, - mcp_elicitations: false, - }; - - let core_policy = v2_policy.to_core(); - assert_eq!( - core_policy, - CoreAskForApproval::Granular(CoreGranularApprovalConfig { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: true, - mcp_elicitations: false, - }) - ); - - let back_to_v2 = AskForApproval::from(core_policy); - assert_eq!(back_to_v2, v2_policy); - } - - #[test] - fn ask_for_approval_granular_defaults_missing_optional_flags_to_false() { - let decoded = serde_json::from_value::(serde_json::json!({ - "granular": { - "sandbox_approval": true, - "rules": false, - "mcp_elicitations": true, - } - })) - .expect("granular approval policy should deserialize"); - - assert_eq!( - decoded, - AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - } - ); - } - - #[test] - fn ask_for_approval_granular_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason( - &AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - }, - ); - - assert_eq!(reason, Some("askForApproval.granular")); - assert_eq!( - crate::experimental_api::ExperimentalApi::experimental_reason( - &AskForApproval::OnRequest, - ), - None - ); - } - - #[test] - fn profile_v2_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&ProfileV2 { - model: None, - model_provider: None, - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: true, - mcp_elicitations: false, - }), - approvals_reviewer: None, - service_tier: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - web_search: None, - tools: None, - chatgpt_base_url: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn config_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { - model: None, - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_provider: None, - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: false, - rules: true, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - }), - approvals_reviewer: None, - sandbox_mode: None, - sandbox_workspace_write: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search: None, - tools: None, - profile: None, - profiles: HashMap::new(), - instructions: None, - developer_instructions: None, - compact_prompt: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - service_tier: None, - analytics: None, - apps: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn config_approvals_reviewer_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { - model: None, - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_provider: None, - approval_policy: None, - approvals_reviewer: Some(ApprovalsReviewer::AutoReview), - sandbox_mode: None, - sandbox_workspace_write: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search: None, - tools: None, - profile: None, - profiles: HashMap::new(), - instructions: None, - developer_instructions: None, - compact_prompt: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - service_tier: None, - analytics: None, - apps: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("config/read.approvalsReviewer")); - } - - #[test] - fn config_nested_profile_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { - model: None, - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_provider: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_mode: None, - sandbox_workspace_write: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search: None, - tools: None, - profile: None, - profiles: HashMap::from([( - "default".to_string(), - ProfileV2 { - model: None, - model_provider: None, - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - }), - approvals_reviewer: None, - service_tier: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - web_search: None, - tools: None, - chatgpt_base_url: None, - additional: HashMap::new(), - }, - )]), - instructions: None, - developer_instructions: None, - compact_prompt: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - service_tier: None, - analytics: None, - apps: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn config_nested_profile_approvals_reviewer_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { - model: None, - review_model: None, - model_context_window: None, - model_auto_compact_token_limit: None, - model_provider: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_mode: None, - sandbox_workspace_write: None, - forced_chatgpt_workspace_id: None, - forced_login_method: None, - web_search: None, - tools: None, - profile: None, - profiles: HashMap::from([( - "default".to_string(), - ProfileV2 { - model: None, - model_provider: None, - approval_policy: None, - approvals_reviewer: Some(ApprovalsReviewer::AutoReview), - service_tier: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - web_search: None, - tools: None, - chatgpt_base_url: None, - additional: HashMap::new(), - }, - )]), - instructions: None, - developer_instructions: None, - compact_prompt: None, - model_reasoning_effort: None, - model_reasoning_summary: None, - model_verbosity: None, - service_tier: None, - analytics: None, - apps: None, - additional: HashMap::new(), - }); - - assert_eq!(reason, Some("config/read.approvalsReviewer")); - } - - #[test] - fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() { - let reason = - crate::experimental_api::ExperimentalApi::experimental_reason(&ConfigRequirements { - allowed_approval_policies: Some(vec![AskForApproval::Granular { - sandbox_approval: true, - rules: true, - skill_approval: false, - request_permissions: false, - mcp_elicitations: false, - }]), - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - allowed_web_search_modes: None, - feature_requirements: None, - hooks: None, - enforce_residency: None, - network: None, - }); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn client_request_thread_start_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason( - &crate::ClientRequest::ThreadStart { - request_id: crate::RequestId::Integer(1), - params: ThreadStartParams { - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: true, - mcp_elicitations: false, - }), - ..Default::default() - }, - }, - ); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn client_request_thread_resume_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason( - &crate::ClientRequest::ThreadResume { - request_id: crate::RequestId::Integer(2), - params: ThreadResumeParams { - thread_id: "thr_123".to_string(), - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: false, - rules: true, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - }), - ..Default::default() - }, - }, - ); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn client_request_thread_fork_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason( - &crate::ClientRequest::ThreadFork { - request_id: crate::RequestId::Integer(3), - params: ThreadForkParams { - thread_id: "thr_456".to_string(), - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: true, - rules: false, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - }), - ..Default::default() - }, - }, - ); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn client_request_turn_start_granular_approval_policy_is_marked_experimental() { - let reason = crate::experimental_api::ExperimentalApi::experimental_reason( - &crate::ClientRequest::TurnStart { - request_id: crate::RequestId::Integer(4), - params: TurnStartParams { - thread_id: "thr_123".to_string(), - input: Vec::new(), - approval_policy: Some(AskForApproval::Granular { - sandbox_approval: false, - rules: true, - skill_approval: false, - request_permissions: false, - mcp_elicitations: true, - }), - ..Default::default() - }, - }, - ); - - assert_eq!(reason, Some("askForApproval.granular")); - } - - #[test] - fn mcp_server_elicitation_response_round_trips_rmcp_result() { - let rmcp_result = rmcp::model::CreateElicitationResult { - action: rmcp::model::ElicitationAction::Accept, - content: Some(json!({ - "confirmed": true, - })), - }; - - let v2_response = McpServerElicitationRequestResponse::from(rmcp_result.clone()); - assert_eq!( - v2_response, - McpServerElicitationRequestResponse { - action: McpServerElicitationAction::Accept, - content: Some(json!({ - "confirmed": true, - })), - meta: None, - } - ); - assert_eq!( - rmcp::model::CreateElicitationResult::from(v2_response), - rmcp_result - ); - } - - #[test] - fn mcp_server_elicitation_request_from_core_url_request() { - let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::Url { - meta: None, - message: "Finish sign-in".to_string(), - url: "https://example.com/complete".to_string(), - elicitation_id: "elicitation-123".to_string(), - }) - .expect("URL request should convert"); - - assert_eq!( - request, - McpServerElicitationRequest::Url { - meta: None, - message: "Finish sign-in".to_string(), - url: "https://example.com/complete".to_string(), - elicitation_id: "elicitation-123".to_string(), - } - ); - } - - #[test] - fn mcp_server_elicitation_request_from_core_form_request() { - let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { - meta: None, - message: "Allow this request?".to_string(), - requested_schema: json!({ - "type": "object", - "properties": { - "confirmed": { - "type": "boolean", - } - }, - "required": ["confirmed"], - }), - }) - .expect("form request should convert"); - - let expected_schema: McpElicitationSchema = serde_json::from_value(json!({ - "type": "object", - "properties": { - "confirmed": { - "type": "boolean", - } - }, - "required": ["confirmed"], - })) - .expect("expected schema should deserialize"); - - assert_eq!( - request, - McpServerElicitationRequest::Form { - meta: None, - message: "Allow this request?".to_string(), - requested_schema: expected_schema, - } - ); - } - - #[test] - fn mcp_elicitation_schema_matches_mcp_2025_11_25_primitives() { - let schema: McpElicitationSchema = serde_json::from_value(json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "type": "object", - "properties": { - "email": { - "type": "string", - "title": "Email", - "description": "Work email address", - "format": "email", - "default": "dev@example.com", - }, - "count": { - "type": "integer", - "title": "Count", - "description": "How many items to create", - "minimum": 1, - "maximum": 5, - "default": 3, - }, - "confirmed": { - "type": "boolean", - "title": "Confirm", - "description": "Approve the pending action", - "default": true, - }, - "legacyChoice": { - "type": "string", - "title": "Action", - "description": "Legacy titled enum form", - "enum": ["allow", "deny"], - "enumNames": ["Allow", "Deny"], - "default": "allow", - }, - }, - "required": ["email", "confirmed"], - })) - .expect("schema should deserialize"); - - assert_eq!( - schema, - McpElicitationSchema { - schema_uri: Some("https://json-schema.org/draft/2020-12/schema".to_string()), - type_: McpElicitationObjectType::Object, - properties: BTreeMap::from([ - ( - "confirmed".to_string(), - McpElicitationPrimitiveSchema::Boolean(McpElicitationBooleanSchema { - type_: McpElicitationBooleanType::Boolean, - title: Some("Confirm".to_string()), - description: Some("Approve the pending action".to_string()), - default: Some(true), - }), - ), - ( - "count".to_string(), - McpElicitationPrimitiveSchema::Number(McpElicitationNumberSchema { - type_: McpElicitationNumberType::Integer, - title: Some("Count".to_string()), - description: Some("How many items to create".to_string()), - minimum: Some(1.0), - maximum: Some(5.0), - default: Some(3.0), - }), - ), - ( - "email".to_string(), - McpElicitationPrimitiveSchema::String(McpElicitationStringSchema { - type_: McpElicitationStringType::String, - title: Some("Email".to_string()), - description: Some("Work email address".to_string()), - min_length: None, - max_length: None, - format: Some(McpElicitationStringFormat::Email), - default: Some("dev@example.com".to_string()), - }), - ), - ( - "legacyChoice".to_string(), - McpElicitationPrimitiveSchema::Enum(McpElicitationEnumSchema::Legacy( - McpElicitationLegacyTitledEnumSchema { - type_: McpElicitationStringType::String, - title: Some("Action".to_string()), - description: Some("Legacy titled enum form".to_string()), - enum_: vec!["allow".to_string(), "deny".to_string()], - enum_names: Some(vec!["Allow".to_string(), "Deny".to_string(),]), - default: Some("allow".to_string()), - }, - )), - ), - ]), - required: Some(vec!["email".to_string(), "confirmed".to_string()]), - } - ); - } - - #[test] - fn mcp_server_elicitation_request_rejects_null_core_form_schema() { - let result = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { - meta: Some(json!({ - "persist": "session", - })), - message: "Allow this request?".to_string(), - requested_schema: JsonValue::Null, - }); - - assert!(result.is_err()); - } - - #[test] - fn mcp_server_elicitation_request_rejects_invalid_core_form_schema() { - let result = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { - meta: None, - message: "Allow this request?".to_string(), - requested_schema: json!({ - "type": "object", - "properties": { - "confirmed": { - "type": "object", - } - }, - }), - }); - - assert!(result.is_err()); - } - - #[test] - fn mcp_server_elicitation_response_serializes_nullable_content() { - let response = McpServerElicitationRequestResponse { - action: McpServerElicitationAction::Decline, - content: None, - meta: None, - }; - - assert_eq!( - serde_json::to_value(response).expect("response should serialize"), - json!({ - "action": "decline", - "content": null, - "_meta": null, - }) - ); - } - - #[test] - fn sandbox_policy_round_trips_workspace_write_access() { - let v2_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: true, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }; - - let core_policy = v2_policy.to_core(); - assert_eq!( - core_policy, - codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - network_access: true, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - } - ); - - let back_to_v2 = SandboxPolicy::from(core_policy); - assert_eq!(back_to_v2, v2_policy); - } - - #[test] - fn sandbox_policy_deserializes_legacy_read_only_full_access_field() { - let policy = serde_json::from_value::(json!({ - "type": "readOnly", - "access": { - "type": "fullAccess" - }, - "networkAccess": true - })) - .expect("read-only policy should ignore legacy fullAccess field"); - assert_eq!( - policy, - SandboxPolicy::ReadOnly { - network_access: true - } - ); - } - - #[test] - fn sandbox_policy_deserializes_legacy_workspace_write_full_access_field() { - let writable_root = absolute_path("/workspace"); - let policy = serde_json::from_value::(json!({ - "type": "workspaceWrite", - "writableRoots": [writable_root], - "readOnlyAccess": { - "type": "fullAccess" - }, - "networkAccess": true, - "excludeTmpdirEnvVar": true, - "excludeSlashTmp": true - })) - .expect("workspace-write policy should ignore legacy fullAccess field"); - assert_eq!( - policy, - SandboxPolicy::WorkspaceWrite { - writable_roots: vec![absolute_path("/workspace")], - network_access: true, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - } - ); - } - - #[test] - fn sandbox_policy_rejects_legacy_read_only_restricted_access_field() { - let err = serde_json::from_value::(json!({ - "type": "readOnly", - "access": { - "type": "restricted", - "includePlatformDefaults": false, - "readableRoots": [] - } - })) - .expect_err("read-only policy should reject removed restricted access field"); - assert!(err.to_string().contains("readOnly.access")); - } - - #[test] - fn sandbox_policy_rejects_legacy_workspace_write_restricted_read_access_field() { - let err = serde_json::from_value::(json!({ - "type": "workspaceWrite", - "writableRoots": [], - "readOnlyAccess": { - "type": "restricted", - "includePlatformDefaults": false, - "readableRoots": [] - }, - "networkAccess": false, - "excludeTmpdirEnvVar": false, - "excludeSlashTmp": false - })) - .expect_err("workspace-write policy should reject removed restricted readOnlyAccess field"); - assert!(err.to_string().contains("workspaceWrite.readOnlyAccess")); - } - - #[test] - fn automatic_approval_review_deserializes_aborted_status() { - let review: GuardianApprovalReview = serde_json::from_value(json!({ - "status": "aborted", - "riskLevel": null, - "userAuthorization": null, - "rationale": null - })) - .expect("aborted automatic review should deserialize"); - assert_eq!( - review, - GuardianApprovalReview { - status: GuardianApprovalReviewStatus::Aborted, - risk_level: None, - user_authorization: None, - rationale: None, - } - ); - } - - #[test] - fn guardian_approval_review_action_round_trips_command_shape() { - let value = json!({ - "type": "command", - "source": "shell", - "command": "rm -rf /tmp/example.sqlite", - "cwd": absolute_path_string("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: absolute_path("tmp"), - } - ); - assert_eq!( - serde_json::to_value(&action).expect("serialize guardian review action"), - value - ); - } - - #[test] - fn network_requirements_deserializes_legacy_fields() { - let requirements: NetworkRequirements = serde_json::from_value(json!({ - "allowedDomains": ["api.openai.com"], - "deniedDomains": ["blocked.example.com"], - "allowUnixSockets": ["/tmp/proxy.sock"] - })) - .expect("legacy network requirements should deserialize"); - - assert_eq!( - requirements, - NetworkRequirements { - enabled: None, - http_port: None, - socks_port: None, - allow_upstream_proxy: None, - dangerously_allow_non_loopback_proxy: None, - dangerously_allow_all_unix_sockets: None, - domains: None, - managed_allowed_domains_only: None, - allowed_domains: Some(vec!["api.openai.com".to_string()]), - denied_domains: Some(vec!["blocked.example.com".to_string()]), - unix_sockets: None, - allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), - allow_local_binding: None, - } - ); - } - - #[test] - fn network_requirements_serializes_canonical_and_legacy_fields() { - let requirements = NetworkRequirements { - enabled: Some(true), - http_port: Some(8080), - socks_port: Some(1080), - allow_upstream_proxy: Some(false), - dangerously_allow_non_loopback_proxy: Some(false), - dangerously_allow_all_unix_sockets: Some(true), - domains: Some(BTreeMap::from([ - ("api.openai.com".to_string(), NetworkDomainPermission::Allow), - ( - "blocked.example.com".to_string(), - NetworkDomainPermission::Deny, - ), - ])), - managed_allowed_domains_only: Some(true), - allowed_domains: Some(vec!["api.openai.com".to_string()]), - denied_domains: Some(vec!["blocked.example.com".to_string()]), - unix_sockets: Some(BTreeMap::from([ - ( - "/tmp/proxy.sock".to_string(), - NetworkUnixSocketPermission::Allow, - ), - ( - "/tmp/ignored.sock".to_string(), - NetworkUnixSocketPermission::None, - ), - ])), - allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), - allow_local_binding: Some(true), - }; - - assert_eq!( - serde_json::to_value(requirements).expect("network requirements should serialize"), - json!({ - "enabled": true, - "httpPort": 8080, - "socksPort": 1080, - "allowUpstreamProxy": false, - "dangerouslyAllowNonLoopbackProxy": false, - "dangerouslyAllowAllUnixSockets": true, - "domains": { - "api.openai.com": "allow", - "blocked.example.com": "deny" - }, - "managedAllowedDomainsOnly": true, - "allowedDomains": ["api.openai.com"], - "deniedDomains": ["blocked.example.com"], - "unixSockets": { - "/tmp/ignored.sock": "none", - "/tmp/proxy.sock": "allow" - }, - "allowUnixSockets": ["/tmp/proxy.sock"], - "allowLocalBinding": true - }) - ); - } - - #[test] - fn core_turn_item_into_thread_item_converts_supported_variants() { - let user_item = TurnItem::UserMessage(UserMessageItem { - id: "user-1".to_string(), - content: vec![ - CoreUserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }, - CoreUserInput::Image { - image_url: "https://example.com/image.png".to_string(), - }, - CoreUserInput::LocalImage { - path: PathBuf::from("local/image.png"), - }, - CoreUserInput::Skill { - name: "skill-creator".to_string(), - path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"), - }, - CoreUserInput::Mention { - name: "Demo App".to_string(), - path: "app://demo-app".to_string(), - }, - ], - }); - - assert_eq!( - ThreadItem::from(user_item), - ThreadItem::UserMessage { - id: "user-1".to_string(), - content: vec![ - UserInput::Text { - text: "hello".to_string(), - text_elements: Vec::new(), - }, - UserInput::Image { - url: "https://example.com/image.png".to_string(), - }, - UserInput::LocalImage { - path: PathBuf::from("local/image.png"), - }, - UserInput::Skill { - name: "skill-creator".to_string(), - path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"), - }, - UserInput::Mention { - name: "Demo App".to_string(), - path: "app://demo-app".to_string(), - }, - ], - } - ); - - let agent_item = TurnItem::AgentMessage(AgentMessageItem { - id: "agent-1".to_string(), - content: vec![ - AgentMessageContent::Text { - text: "Hello ".to_string(), - }, - AgentMessageContent::Text { - text: "world".to_string(), - }, - ], - phase: None, - memory_citation: None, - }); - - assert_eq!( - ThreadItem::from(agent_item), - ThreadItem::AgentMessage { - id: "agent-1".to_string(), - text: "Hello world".to_string(), - phase: None, - memory_citation: None, - } - ); - - let agent_item_with_phase = TurnItem::AgentMessage(AgentMessageItem { - id: "agent-2".to_string(), - content: vec![AgentMessageContent::Text { - text: "final".to_string(), - }], - phase: Some(MessagePhase::FinalAnswer), - memory_citation: Some(CoreMemoryCitation { - entries: vec![CoreMemoryCitationEntry { - path: "MEMORY.md".to_string(), - line_start: 1, - line_end: 2, - note: "summary".to_string(), - }], - rollout_ids: vec!["rollout-1".to_string()], - }), - }); - - assert_eq!( - ThreadItem::from(agent_item_with_phase), - ThreadItem::AgentMessage { - id: "agent-2".to_string(), - text: "final".to_string(), - phase: Some(MessagePhase::FinalAnswer), - memory_citation: Some(MemoryCitation { - entries: vec![MemoryCitationEntry { - path: "MEMORY.md".to_string(), - line_start: 1, - line_end: 2, - note: "summary".to_string(), - }], - thread_ids: vec!["rollout-1".to_string()], - }), - } - ); - - let reasoning_item = TurnItem::Reasoning(ReasoningItem { - id: "reasoning-1".to_string(), - summary_text: vec!["line one".to_string(), "line two".to_string()], - raw_content: vec![], - }); - - assert_eq!( - ThreadItem::from(reasoning_item), - ThreadItem::Reasoning { - id: "reasoning-1".to_string(), - summary: vec!["line one".to_string(), "line two".to_string()], - content: vec![], - } - ); - - let search_item = TurnItem::WebSearch(WebSearchItem { - id: "search-1".to_string(), - query: "docs".to_string(), - action: CoreWebSearchAction::Search { - query: Some("docs".to_string()), - queries: None, - }, - }); - - assert_eq!( - ThreadItem::from(search_item), - ThreadItem::WebSearch { - id: "search-1".to_string(), - query: "docs".to_string(), - action: Some(WebSearchAction::Search { - query: Some("docs".to_string()), - queries: None, - }), - } - ); - - let image_view_item = TurnItem::ImageView(ImageViewItem { - id: "view-image-1".to_string(), - path: test_path_buf("/tmp/view-image.png").abs(), - }); - - assert_eq!( - ThreadItem::from(image_view_item), - ThreadItem::ImageView { - id: "view-image-1".to_string(), - path: test_path_buf("/tmp/view-image.png").abs(), - } - ); - - let file_change_item = TurnItem::FileChange(FileChangeItem { - id: "patch-1".to_string(), - changes: [( - PathBuf::from("README.md"), - codex_protocol::protocol::FileChange::Add { - content: "hello\n".to_string(), - }, - )] - .into_iter() - .collect(), - status: Some(codex_protocol::protocol::PatchApplyStatus::Completed), - auto_approved: None, - stdout: Some("Done!".to_string()), - stderr: Some(String::new()), - }); - - assert_eq!( - ThreadItem::from(file_change_item), - ThreadItem::FileChange { - id: "patch-1".to_string(), - changes: vec![FileUpdateChange { - path: "README.md".to_string(), - kind: PatchChangeKind::Add, - diff: "hello\n".to_string(), - }], - status: PatchApplyStatus::Completed, - } - ); - } - - #[test] - fn skills_list_params_serialization_uses_force_reload() { - assert_eq!( - serde_json::to_value(SkillsListParams { - cwds: Vec::new(), - force_reload: false, - per_cwd_extra_user_roots: None, - }) - .unwrap(), - json!({ - "perCwdExtraUserRoots": null, - }), - ); - - assert_eq!( - serde_json::to_value(SkillsListParams { - cwds: vec![PathBuf::from("/repo")], - force_reload: true, - per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { - cwd: PathBuf::from("/repo"), - extra_user_roots: vec![ - PathBuf::from("/shared/skills"), - PathBuf::from("/tmp/x") - ], - }]), - }) - .unwrap(), - json!({ - "cwds": ["/repo"], - "forceReload": true, - "perCwdExtraUserRoots": [ - { - "cwd": "/repo", - "extraUserRoots": ["/shared/skills", "/tmp/x"], - } - ], - }), - ); - } - - #[test] - fn plugin_source_serializes_local_git_and_remote_variants() { - let local_path = if cfg!(windows) { - r"C:\plugins\linear" - } else { - "/plugins/linear" - }; - let local_path = AbsolutePathBuf::try_from(PathBuf::from(local_path)).unwrap(); - let local_path_json = local_path.as_path().display().to_string(); - - assert_eq!( - serde_json::to_value(PluginSource::Local { path: local_path }).unwrap(), - json!({ - "type": "local", - "path": local_path_json, - }), - ); - - assert_eq!( - serde_json::to_value(PluginSource::Git { - url: "https://github.com/openai/example.git".to_string(), - path: Some("plugins/example".to_string()), - ref_name: Some("main".to_string()), - sha: Some("abc123".to_string()), - }) - .unwrap(), - json!({ - "type": "git", - "url": "https://github.com/openai/example.git", - "path": "plugins/example", - "refName": "main", - "sha": "abc123", - }), - ); - - assert_eq!( - serde_json::to_value(PluginSource::Remote).unwrap(), - json!({ - "type": "remote", - }), - ); - } - - #[test] - fn marketplace_add_params_serialization_uses_optional_ref_name_and_sparse_paths() { - assert_eq!( - serde_json::to_value(MarketplaceAddParams { - source: "owner/repo".to_string(), - ref_name: None, - sparse_paths: None, - }) - .unwrap(), - json!({ - "source": "owner/repo", - "refName": null, - "sparsePaths": null, - }), - ); - - assert_eq!( - serde_json::to_value(MarketplaceAddParams { - source: "owner/repo".to_string(), - ref_name: Some("main".to_string()), - sparse_paths: Some(vec!["plugins/foo".to_string()]), - }) - .unwrap(), - json!({ - "source": "owner/repo", - "refName": "main", - "sparsePaths": ["plugins/foo"], - }), - ); - } - - #[test] - fn marketplace_upgrade_params_serialization_uses_optional_marketplace_name() { - assert_eq!( - serde_json::to_value(MarketplaceUpgradeParams { - marketplace_name: None, - }) - .unwrap(), - json!({ - "marketplaceName": null, - }), - ); - - assert_eq!( - serde_json::from_value::(json!({})).unwrap(), - MarketplaceUpgradeParams { - marketplace_name: None, - }, - ); - - assert_eq!( - serde_json::to_value(MarketplaceUpgradeParams { - marketplace_name: Some("debug".to_string()), - }) - .unwrap(), - json!({ - "marketplaceName": "debug", - }), - ); - } - - #[test] - fn plugin_marketplace_entry_serializes_remote_only_path_as_null() { - assert_eq!( - serde_json::to_value(PluginMarketplaceEntry { - name: "openai-curated".to_string(), - path: None, - interface: None, - plugins: Vec::new(), - }) - .unwrap(), - json!({ - "name": "openai-curated", - "path": null, - "interface": null, - "plugins": [], - }), - ); - } - - #[test] - fn plugin_interface_serializes_local_paths_and_remote_urls_separately() { - let composer_icon = if cfg!(windows) { - r"C:\plugins\linear\icon.png" - } else { - "/plugins/linear/icon.png" - }; - let composer_icon = AbsolutePathBuf::try_from(PathBuf::from(composer_icon)).unwrap(); - let composer_icon_json = composer_icon.as_path().display().to_string(); - - let interface = PluginInterface { - display_name: Some("Linear".to_string()), - short_description: None, - long_description: None, - developer_name: None, - category: Some("Productivity".to_string()), - capabilities: Vec::new(), - website_url: None, - privacy_policy_url: None, - terms_of_service_url: None, - default_prompt: None, - brand_color: None, - composer_icon: Some(composer_icon), - composer_icon_url: Some("https://example.com/linear/icon.png".to_string()), - logo: None, - logo_url: Some("https://example.com/linear/logo.png".to_string()), - screenshots: Vec::new(), - screenshot_urls: vec!["https://example.com/linear/screenshot.png".to_string()], - }; - - assert_eq!( - serde_json::to_value(interface).unwrap(), - json!({ - "displayName": "Linear", - "shortDescription": null, - "longDescription": null, - "developerName": null, - "category": "Productivity", - "capabilities": [], - "websiteUrl": null, - "privacyPolicyUrl": null, - "termsOfServiceUrl": null, - "defaultPrompt": null, - "brandColor": null, - "composerIcon": composer_icon_json, - "composerIconUrl": "https://example.com/linear/icon.png", - "logo": null, - "logoUrl": "https://example.com/linear/logo.png", - "screenshots": [], - "screenshotUrls": ["https://example.com/linear/screenshot.png"], - }), - ); - } - - #[test] - fn plugin_list_params_ignore_removed_force_remote_sync_field() { - assert_eq!( - serde_json::from_value::(json!({ - "cwds": null, - "forceRemoteSync": true, - })) - .unwrap(), - PluginListParams { cwds: None }, - ); - } - - #[test] - fn plugin_read_params_serialization_uses_install_source_fields() { - let marketplace_path = if cfg!(windows) { - r"C:\plugins\marketplace.json" - } else { - "/plugins/marketplace.json" - }; - let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap(); - let marketplace_path_json = marketplace_path.as_path().display().to_string(); - assert_eq!( - serde_json::to_value(PluginReadParams { - marketplace_path: Some(marketplace_path.clone()), - remote_marketplace_name: None, - plugin_name: "gmail".to_string(), - }) - .unwrap(), - json!({ - "marketplacePath": marketplace_path_json, - "remoteMarketplaceName": null, - "pluginName": "gmail", - }), - ); - - assert_eq!( - serde_json::from_value::(json!({ - "marketplacePath": marketplace_path_json, - "pluginName": "gmail", - "forceRemoteSync": true, - })) - .unwrap(), - PluginReadParams { - marketplace_path: Some(marketplace_path), - remote_marketplace_name: None, - plugin_name: "gmail".to_string(), - }, - ); - - assert_eq!( - serde_json::from_value::(json!({ - "remoteMarketplaceName": "openai-curated", - "pluginName": "gmail", - })) - .unwrap(), - PluginReadParams { - marketplace_path: None, - remote_marketplace_name: Some("openai-curated".to_string()), - plugin_name: "gmail".to_string(), - }, - ); - } - - #[test] - fn plugin_install_params_serialization_omits_force_remote_sync() { - let marketplace_path = if cfg!(windows) { - r"C:\plugins\marketplace.json" - } else { - "/plugins/marketplace.json" - }; - let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap(); - let marketplace_path_json = marketplace_path.as_path().display().to_string(); - assert_eq!( - serde_json::to_value(PluginInstallParams { - marketplace_path: Some(marketplace_path.clone()), - remote_marketplace_name: None, - plugin_name: "gmail".to_string(), - }) - .unwrap(), - json!({ - "marketplacePath": marketplace_path_json, - "remoteMarketplaceName": null, - "pluginName": "gmail", - }), - ); - - assert_eq!( - serde_json::from_value::(json!({ - "marketplacePath": marketplace_path_json, - "pluginName": "gmail", - "forceRemoteSync": true, - })) - .unwrap(), - PluginInstallParams { - marketplace_path: Some(marketplace_path), - remote_marketplace_name: None, - plugin_name: "gmail".to_string(), - }, - ); - - assert_eq!( - serde_json::from_value::(json!({ - "remoteMarketplaceName": "openai-curated", - "pluginName": "gmail", - "forceRemoteSync": true, - })) - .unwrap(), - PluginInstallParams { - marketplace_path: None, - remote_marketplace_name: Some("openai-curated".to_string()), - plugin_name: "gmail".to_string(), - }, - ); - } - - #[test] - fn plugin_skill_read_params_serialization_uses_remote_plugin_id() { - assert_eq!( - serde_json::to_value(PluginSkillReadParams { - remote_marketplace_name: "chatgpt-global".to_string(), - remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), - skill_name: "plan-work".to_string(), - }) - .unwrap(), - json!({ - "remoteMarketplaceName": "chatgpt-global", - "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", - "skillName": "plan-work", - }), - ); - } - - #[test] - fn plugin_share_params_and_response_serialization_use_camel_case_fields() { - let plugin_path = if cfg!(windows) { - r"C:\plugins\gmail" - } else { - "/plugins/gmail" - }; - let plugin_path = AbsolutePathBuf::try_from(PathBuf::from(plugin_path)).unwrap(); - let plugin_path_json = plugin_path.as_path().display().to_string(); - - assert_eq!( - serde_json::to_value(PluginShareSaveParams { - plugin_path: plugin_path.clone(), - remote_plugin_id: None, - }) - .unwrap(), - json!({ - "pluginPath": plugin_path_json, - "remotePluginId": null, - }), - ); - - assert_eq!( - serde_json::to_value(PluginShareSaveParams { - plugin_path, - remote_plugin_id: Some( - "plugins~Plugin_00000000000000000000000000000000".to_string(), - ), - }) - .unwrap(), - json!({ - "pluginPath": plugin_path_json, - "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", - }), - ); - - assert_eq!( - serde_json::to_value(PluginShareSaveResponse { - remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), - share_url: String::new(), - }) - .unwrap(), - json!({ - "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", - "shareUrl": "", - }), - ); - - assert_eq!( - serde_json::from_value::(json!({})).unwrap(), - PluginShareListParams {}, - ); - - assert_eq!( - serde_json::to_value(PluginShareDeleteParams { - remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), - }) - .unwrap(), - json!({ - "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", - }), - ); - } - - #[test] - fn plugin_share_list_response_serializes_share_items() { - assert_eq!( - serde_json::to_value(PluginShareListResponse { - data: vec![PluginShareListItem { - plugin: PluginSummary { - id: "plugins~Plugin_00000000000000000000000000000000".to_string(), - name: "gmail".to_string(), - source: PluginSource::Remote, - installed: false, - enabled: false, - install_policy: PluginInstallPolicy::Available, - auth_policy: PluginAuthPolicy::OnUse, - availability: PluginAvailability::Available, - interface: None, - }, - share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), - local_plugin_path: None, - }], - }) - .unwrap(), - json!({ - "data": [{ - "plugin": { - "id": "plugins~Plugin_00000000000000000000000000000000", - "name": "gmail", - "source": { "type": "remote" }, - "installed": false, - "enabled": false, - "installPolicy": "AVAILABLE", - "authPolicy": "ON_USE", - "availability": "AVAILABLE", - "interface": null, - }, - "shareUrl": "https://chatgpt.example/plugins/share/share-key-1", - "localPluginPath": null, - }], - }), - ); - } - - #[test] - fn plugin_summary_defaults_missing_availability_to_available() { - let summary: PluginSummary = serde_json::from_value(json!({ - "id": "plugins~Plugin_00000000000000000000000000000000", - "name": "gmail", - "source": { "type": "remote" }, - "installed": false, - "enabled": false, - "installPolicy": "AVAILABLE", - "authPolicy": "ON_USE", - "interface": null, - })) - .unwrap(); - - assert_eq!(summary.availability, PluginAvailability::Available); - } - - #[test] - fn plugin_availability_deserializes_enabled_alias() { - let availability: PluginAvailability = serde_json::from_value(json!("ENABLED")).unwrap(); - - assert_eq!(availability, PluginAvailability::Available); - assert_eq!( - serde_json::to_value(availability).unwrap(), - json!("AVAILABLE") - ); - } - - #[test] - fn plugin_uninstall_params_serialization_omits_force_remote_sync() { - assert_eq!( - serde_json::to_value(PluginUninstallParams { - plugin_id: "gmail@openai-curated".to_string(), - }) - .unwrap(), - json!({ - "pluginId": "gmail@openai-curated", - }), - ); - - assert_eq!( - serde_json::from_value::(json!({ - "pluginId": "gmail@openai-curated", - "forceRemoteSync": true, - })) - .unwrap(), - PluginUninstallParams { - plugin_id: "gmail@openai-curated".to_string(), - }, - ); - - assert_eq!( - serde_json::to_value(PluginUninstallParams { - plugin_id: "plugins~Plugin_gmail".to_string(), - }) - .unwrap(), - json!({ - "pluginId": "plugins~Plugin_gmail", - }), - ); - - assert_eq!( - serde_json::from_value::(json!({ - "pluginId": "plugins~Plugin_gmail", - "forceRemoteSync": true, - })) - .unwrap(), - PluginUninstallParams { - plugin_id: "plugins~Plugin_gmail".to_string(), - }, - ); - } - - #[test] - fn marketplace_remove_response_serializes_nullable_installed_root() { - let installed_root = if cfg!(windows) { - r"C:\marketplaces\debug" - } else { - "/tmp/marketplaces/debug" - }; - let installed_root = AbsolutePathBuf::try_from(PathBuf::from(installed_root)).unwrap(); - let installed_root_json = installed_root.as_path().display().to_string(); - assert_eq!( - serde_json::to_value(MarketplaceRemoveResponse { - marketplace_name: "debug".to_string(), - installed_root: Some(installed_root), - }) - .unwrap(), - json!({ - "marketplaceName": "debug", - "installedRoot": installed_root_json, - }), - ); - - assert_eq!( - serde_json::to_value(MarketplaceRemoveResponse { - marketplace_name: "debug".to_string(), - installed_root: None, - }) - .unwrap(), - json!({ - "marketplaceName": "debug", - "installedRoot": null, - }), - ); - } - - #[test] - fn marketplace_upgrade_response_serializes_camel_case_fields() { - let upgraded_root = if cfg!(windows) { - r"C:\marketplaces\debug" - } else { - "/tmp/marketplaces/debug" - }; - let upgraded_root = AbsolutePathBuf::try_from(PathBuf::from(upgraded_root)).unwrap(); - let upgraded_root_json = upgraded_root.as_path().display().to_string(); - - assert_eq!( - serde_json::to_value(MarketplaceUpgradeResponse { - selected_marketplaces: vec!["debug".to_string()], - upgraded_roots: vec![upgraded_root], - errors: vec![MarketplaceUpgradeErrorInfo { - marketplace_name: "broken".to_string(), - message: "failed to clone".to_string(), - }], - }) - .unwrap(), - json!({ - "selectedMarketplaces": ["debug"], - "upgradedRoots": [upgraded_root_json], - "errors": [{ - "marketplaceName": "broken", - "message": "failed to clone", - }], - }), - ); - } - - #[test] - fn codex_error_info_serializes_http_status_code_in_camel_case() { - let value = CodexErrorInfo::ResponseTooManyFailedAttempts { - http_status_code: Some(401), - }; - - assert_eq!( - serde_json::to_value(value).unwrap(), - json!({ - "responseTooManyFailedAttempts": { - "httpStatusCode": 401 - } - }) - ); - } - - #[test] - fn codex_error_info_serializes_cyber_policy_in_camel_case() { - assert_eq!( - serde_json::to_value(CodexErrorInfo::CyberPolicy).unwrap(), - json!("cyberPolicy") - ); - } - - #[test] - fn codex_error_info_serializes_active_turn_not_steerable_turn_kind_in_camel_case() { - let value = CodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: NonSteerableTurnKind::Review, - }; - - assert_eq!( - serde_json::to_value(value).unwrap(), - json!({ - "activeTurnNotSteerable": { - "turnKind": "review" - } - }) - ); - } - - #[test] - fn dynamic_tool_response_serializes_content_items() { - let value = serde_json::to_value(DynamicToolCallResponse { - content_items: vec![DynamicToolCallOutputContentItem::InputText { - text: "dynamic-ok".to_string(), - }], - success: true, - }) - .unwrap(); - - assert_eq!( - value, - json!({ - "contentItems": [ - { - "type": "inputText", - "text": "dynamic-ok" - } - ], - "success": true, - }) - ); - } - - #[test] - fn dynamic_tool_response_serializes_text_and_image_content_items() { - let value = serde_json::to_value(DynamicToolCallResponse { - content_items: vec![ - DynamicToolCallOutputContentItem::InputText { - text: "dynamic-ok".to_string(), - }, - DynamicToolCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - }, - ], - success: true, - }) - .unwrap(); - - assert_eq!( - value, - json!({ - "contentItems": [ - { - "type": "inputText", - "text": "dynamic-ok" - }, - { - "type": "inputImage", - "imageUrl": "data:image/png;base64,AAA" - } - ], - "success": true, - }) - ); - } - - #[test] - fn dynamic_tool_spec_deserializes_defer_loading() { - let value = json!({ - "name": "lookup_ticket", - "description": "Fetch a ticket", - "inputSchema": { - "type": "object", - "properties": { - "id": { "type": "string" } - } - }, - "deferLoading": true, - }); - - let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize"); - - assert_eq!( - actual, - DynamicToolSpec { - namespace: None, - name: "lookup_ticket".to_string(), - description: "Fetch a ticket".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "id": { "type": "string" } - } - }), - defer_loading: true, - } - ); - } - - #[test] - fn dynamic_tool_spec_legacy_expose_to_context_inverts_to_defer_loading() { - let value = json!({ - "name": "lookup_ticket", - "description": "Fetch a ticket", - "inputSchema": { - "type": "object", - "properties": {} - }, - "exposeToContext": false, - }); - - let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize"); - - assert!(actual.defer_loading); - } - - #[test] - fn thread_start_params_preserve_explicit_null_service_tier() { - let params: ThreadStartParams = serde_json::from_value(json!({ "serviceTier": null })) - .expect("params should deserialize"); - assert_eq!(params.service_tier, Some(None)); - - let serialized = serde_json::to_value(¶ms).expect("params should serialize"); - assert_eq!( - serialized.get("serviceTier"), - Some(&serde_json::Value::Null) - ); - - let serialized_without_override = - serde_json::to_value(ThreadStartParams::default()).expect("params should serialize"); - assert_eq!(serialized_without_override.get("serviceTier"), None); - } - - #[test] - fn thread_lifecycle_responses_default_missing_compat_fields() { - let response = json!({ - "thread": { - "id": "thread-id", - "forkedFromId": null, - "preview": "", - "ephemeral": false, - "modelProvider": "openai", - "createdAt": 1, - "updatedAt": 1, - "status": { "type": "idle" }, - "path": null, - "cwd": absolute_path_string("tmp"), - "cliVersion": "0.0.0", - "source": "exec", - "agentNickname": null, - "agentRole": null, - "gitInfo": null, - "name": null, - "turns": [] - }, - "model": "gpt-5", - "modelProvider": "openai", - "serviceTier": null, - "cwd": absolute_path_string("tmp"), - "approvalPolicy": "on-failure", - "approvalsReviewer": "user", - "sandbox": { "type": "dangerFullAccess" }, - "reasoningEffort": null - }); - - let start: ThreadStartResponse = - serde_json::from_value(response.clone()).expect("thread/start response"); - let resume: ThreadResumeResponse = - serde_json::from_value(response.clone()).expect("thread/resume response"); - let fork: ThreadForkResponse = - serde_json::from_value(response).expect("thread/fork response"); - - assert_eq!(start.instruction_sources, Vec::::new()); - assert_eq!(resume.instruction_sources, Vec::::new()); - assert_eq!(fork.instruction_sources, Vec::::new()); - assert_eq!(start.permission_profile, None); - assert_eq!(resume.permission_profile, None); - assert_eq!(fork.permission_profile, None); - assert_eq!(start.active_permission_profile, None); - assert_eq!(resume.active_permission_profile, None); - assert_eq!(fork.active_permission_profile, None); - } - - #[test] - fn turn_start_params_preserve_explicit_null_service_tier() { - let params: TurnStartParams = serde_json::from_value(json!({ - "threadId": "thread_123", - "input": [], - "serviceTier": null - })) - .expect("params should deserialize"); - assert_eq!(params.service_tier, Some(None)); - - let serialized = serde_json::to_value(¶ms).expect("params should serialize"); - assert_eq!( - serialized.get("serviceTier"), - Some(&serde_json::Value::Null) - ); - - let without_override = TurnStartParams { - thread_id: "thread_123".to_string(), - input: vec![], - responsesapi_client_metadata: None, - environments: None, - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox_policy: None, - permissions: None, - model: None, - service_tier: None, - effort: None, - summary: None, - output_schema: None, - collaboration_mode: None, - personality: None, - }; - let serialized_without_override = - serde_json::to_value(&without_override).expect("params should serialize"); - assert_eq!(serialized_without_override.get("serviceTier"), None); - } - - #[test] - fn turn_start_params_round_trip_environments() { - let cwd = test_absolute_path(); - let params: TurnStartParams = serde_json::from_value(json!({ - "threadId": "thread_123", - "input": [], - "environments": [ - { - "environmentId": "local", - "cwd": cwd - } - ], - })) - .expect("params should deserialize"); - - assert_eq!( - params.environments, - Some(vec![TurnEnvironmentParams { - environment_id: "local".to_string(), - cwd: cwd.clone(), - }]) - ); - assert_eq!( - crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), - Some("turn/start.environments") - ); - - let serialized = serde_json::to_value(¶ms).expect("params should serialize"); - assert_eq!( - serialized.get("environments"), - Some(&json!([ - { - "environmentId": "local", - "cwd": cwd - } - ])) - ); - } - - #[test] - fn turn_start_params_preserve_empty_environments() { - let params: TurnStartParams = serde_json::from_value(json!({ - "threadId": "thread_123", - "input": [], - "environments": [], - })) - .expect("params should deserialize"); - - assert_eq!(params.environments, Some(Vec::new())); - assert_eq!( - crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), - Some("turn/start.environments") - ); - - let serialized = serde_json::to_value(¶ms).expect("params should serialize"); - assert_eq!(serialized.get("environments"), Some(&json!([]))); - } - - #[test] - fn turn_start_params_treat_null_or_omitted_environments_as_default() { - let null_environments: TurnStartParams = serde_json::from_value(json!({ - "threadId": "thread_123", - "input": [], - "environments": null, - })) - .expect("params should deserialize"); - let omitted_environments: TurnStartParams = serde_json::from_value(json!({ - "threadId": "thread_123", - "input": [], - })) - .expect("params should deserialize"); - - assert_eq!(null_environments.environments, None); - assert_eq!(omitted_environments.environments, None); - assert_eq!( - crate::experimental_api::ExperimentalApi::experimental_reason(&null_environments), - None - ); - assert_eq!( - crate::experimental_api::ExperimentalApi::experimental_reason(&omitted_environments), - None - ); - } - - #[test] - fn turn_start_params_reject_relative_environment_cwd() { - let err = serde_json::from_value::(json!({ - "threadId": "thread_123", - "input": [], - "environments": [ - { - "environmentId": "local", - "cwd": "relative" - } - ], - })) - .expect_err("relative environment cwd should fail"); - - assert!( - err.to_string() - .contains("AbsolutePathBuf deserialized without a base path"), - "unexpected error: {err}" - ); - } -} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs new file mode 100644 index 0000000000..efb4a26f60 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -0,0 +1,383 @@ +use crate::protocol::common::AuthMode; +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::account::PlanType; +use codex_protocol::account::ProviderAccount; +use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; +use codex_protocol::protocol::RateLimitReachedType as CoreRateLimitReachedType; +use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; +use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum Account { + #[serde(rename = "apiKey", rename_all = "camelCase")] + #[ts(rename = "apiKey", rename_all = "camelCase")] + ApiKey {}, + + #[serde(rename = "chatgpt", rename_all = "camelCase")] + #[ts(rename = "chatgpt", rename_all = "camelCase")] + Chatgpt { email: String, plan_type: PlanType }, + + #[serde(rename = "amazonBedrock", rename_all = "camelCase")] + #[ts(rename = "amazonBedrock", rename_all = "camelCase")] + AmazonBedrock {}, +} + +impl From for Account { + fn from(account: ProviderAccount) -> Self { + match account { + ProviderAccount::ApiKey => Self::ApiKey {}, + ProviderAccount::Chatgpt { email, plan_type } => Self::Chatgpt { email, plan_type }, + ProviderAccount::AmazonBedrock => Self::AmazonBedrock {}, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(tag = "type")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum LoginAccountParams { + #[serde(rename = "apiKey", rename_all = "camelCase")] + #[ts(rename = "apiKey", rename_all = "camelCase")] + ApiKey { + #[serde(rename = "apiKey")] + #[ts(rename = "apiKey")] + api_key: String, + }, + #[serde(rename = "chatgpt", rename_all = "camelCase")] + #[ts(rename = "chatgpt", rename_all = "camelCase")] + Chatgpt { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + codex_streamlined_login: bool, + }, + #[serde(rename = "chatgptDeviceCode")] + #[ts(rename = "chatgptDeviceCode")] + ChatgptDeviceCode, + /// [UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE. + /// The access token must contain the same scopes that Codex-managed ChatGPT auth tokens have. + #[experimental("account/login/start.chatgptAuthTokens")] + #[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")] + #[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")] + ChatgptAuthTokens { + /// Access token (JWT) supplied by the client. + /// This token is used for backend API requests and email extraction. + access_token: String, + /// Workspace/account identifier supplied by the client. + chatgpt_account_id: String, + /// Optional plan type supplied by the client. + /// + /// When `null`, Codex attempts to derive the plan type from access-token + /// claims. If unavailable, the plan defaults to `unknown`. + #[ts(optional = nullable)] + chatgpt_plan_type: Option, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum LoginAccountResponse { + #[serde(rename = "apiKey", rename_all = "camelCase")] + #[ts(rename = "apiKey", rename_all = "camelCase")] + ApiKey {}, + #[serde(rename = "chatgpt", rename_all = "camelCase")] + #[ts(rename = "chatgpt", rename_all = "camelCase")] + Chatgpt { + // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. + // Convert to/from UUIDs at the application layer as needed. + login_id: String, + /// URL the client should open in a browser to initiate the OAuth flow. + auth_url: String, + }, + #[serde(rename = "chatgptDeviceCode", rename_all = "camelCase")] + #[ts(rename = "chatgptDeviceCode", rename_all = "camelCase")] + ChatgptDeviceCode { + // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. + // Convert to/from UUIDs at the application layer as needed. + login_id: String, + /// URL the client should open in a browser to complete device code authorization. + verification_url: String, + /// One-time code the user must enter after signing in. + user_code: String, + }, + #[serde(rename = "chatgptAuthTokens", rename_all = "camelCase")] + #[ts(rename = "chatgptAuthTokens", rename_all = "camelCase")] + ChatgptAuthTokens {}, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CancelLoginAccountParams { + pub login_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CancelLoginAccountStatus { + Canceled, + NotFound, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CancelLoginAccountResponse { + pub status: CancelLoginAccountStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct LogoutAccountResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ChatgptAuthTokensRefreshReason { + /// Codex attempted a backend request and received `401 Unauthorized`. + Unauthorized, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ChatgptAuthTokensRefreshParams { + pub reason: ChatgptAuthTokensRefreshReason, + /// Workspace/account identifier that Codex was previously using. + /// + /// Clients that manage multiple accounts/workspaces can use this as a hint + /// to refresh the token for the correct workspace. + /// + /// This may be `null` when the prior auth state did not include a workspace + /// identifier (`chatgpt_account_id`). + #[ts(optional = nullable)] + pub previous_account_id: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ChatgptAuthTokensRefreshResponse { + pub access_token: String, + pub chatgpt_account_id: String, + pub chatgpt_plan_type: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountRateLimitsResponse { + /// Backward-compatible single-bucket view; mirrors the historical payload. + pub rate_limits: RateLimitSnapshot, + /// Multi-bucket view keyed by metered `limit_id` (for example, `codex`). + pub rate_limits_by_limit_id: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SendAddCreditsNudgeEmailParams { + pub credit_type: AddCreditsNudgeCreditType, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/", rename_all = "snake_case")] +pub enum AddCreditsNudgeCreditType { + Credits, + UsageLimit, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SendAddCreditsNudgeEmailResponse { + pub status: AddCreditsNudgeEmailStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/", rename_all = "snake_case")] +pub enum AddCreditsNudgeEmailStatus { + Sent, + CooldownActive, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountParams { + /// When `true`, requests a proactive token refresh before returning. + /// + /// In managed auth mode this triggers the normal refresh-token flow. In + /// external auth mode this flag is ignored. Clients should refresh tokens + /// themselves and call `account/login/start` with `chatgptAuthTokens`. + #[serde(default)] + pub refresh_token: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountResponse { + pub account: Option, + pub requires_openai_auth: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountUpdatedNotification { + pub auth_mode: Option, + pub plan_type: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountRateLimitsUpdatedNotification { + pub rate_limits: RateLimitSnapshot, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RateLimitSnapshot { + pub limit_id: Option, + pub limit_name: Option, + pub primary: Option, + pub secondary: Option, + pub credits: Option, + pub plan_type: Option, + pub rate_limit_reached_type: Option, +} + +impl From for RateLimitSnapshot { + fn from(value: CoreRateLimitSnapshot) -> Self { + Self { + limit_id: value.limit_id, + limit_name: value.limit_name, + primary: value.primary.map(RateLimitWindow::from), + secondary: value.secondary.map(RateLimitWindow::from), + credits: value.credits.map(CreditsSnapshot::from), + plan_type: value.plan_type, + rate_limit_reached_type: value + .rate_limit_reached_type + .map(RateLimitReachedType::from), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/", rename_all = "snake_case")] +pub enum RateLimitReachedType { + RateLimitReached, + WorkspaceOwnerCreditsDepleted, + WorkspaceMemberCreditsDepleted, + WorkspaceOwnerUsageLimitReached, + WorkspaceMemberUsageLimitReached, +} + +impl From for RateLimitReachedType { + fn from(value: CoreRateLimitReachedType) -> Self { + match value { + CoreRateLimitReachedType::RateLimitReached => Self::RateLimitReached, + CoreRateLimitReachedType::WorkspaceOwnerCreditsDepleted => { + Self::WorkspaceOwnerCreditsDepleted + } + CoreRateLimitReachedType::WorkspaceMemberCreditsDepleted => { + Self::WorkspaceMemberCreditsDepleted + } + CoreRateLimitReachedType::WorkspaceOwnerUsageLimitReached => { + Self::WorkspaceOwnerUsageLimitReached + } + CoreRateLimitReachedType::WorkspaceMemberUsageLimitReached => { + Self::WorkspaceMemberUsageLimitReached + } + } + } +} + +impl From for CoreRateLimitReachedType { + fn from(value: RateLimitReachedType) -> Self { + match value { + RateLimitReachedType::RateLimitReached => Self::RateLimitReached, + RateLimitReachedType::WorkspaceOwnerCreditsDepleted => { + Self::WorkspaceOwnerCreditsDepleted + } + RateLimitReachedType::WorkspaceMemberCreditsDepleted => { + Self::WorkspaceMemberCreditsDepleted + } + RateLimitReachedType::WorkspaceOwnerUsageLimitReached => { + Self::WorkspaceOwnerUsageLimitReached + } + RateLimitReachedType::WorkspaceMemberUsageLimitReached => { + Self::WorkspaceMemberUsageLimitReached + } + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RateLimitWindow { + pub used_percent: i32, + #[ts(type = "number | null")] + pub window_duration_mins: Option, + #[ts(type = "number | null")] + pub resets_at: Option, +} + +impl From for RateLimitWindow { + fn from(value: CoreRateLimitWindow) -> Self { + Self { + used_percent: value.used_percent.round() as i32, + window_duration_mins: value.window_minutes, + resets_at: value.resets_at, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CreditsSnapshot { + pub has_credits: bool, + pub unlimited: bool, + pub balance: Option, +} + +impl From for CreditsSnapshot { + fn from(value: CoreCreditsSnapshot) -> Self { + Self { + has_credits: value.has_credits, + unlimited: value.unlimited, + balance: value.balance, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountLoginCompletedNotification { + // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. + // Convert to/from UUIDs at the application layer as needed. + pub login_id: Option, + pub success: bool, + pub error: Option, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/apps.rs b/codex-rs/app-server-protocol/src/protocol/v2/apps.rs new file mode 100644 index 0000000000..9f46525e6c --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/apps.rs @@ -0,0 +1,146 @@ +use super::shared::default_enabled; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - list available apps/connectors. +pub struct AppsListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + #[ts(optional = nullable)] + pub limit: Option, + /// Optional thread id used to evaluate app feature gating from that thread's config. + #[ts(optional = nullable)] + pub thread_id: Option, + /// When true, bypass app caches and fetch the latest data from sources. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub force_refetch: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - app metadata returned by app-list APIs. +pub struct AppBranding { + pub category: Option, + pub developer: Option, + pub website: Option, + pub privacy_policy: Option, + pub terms_of_service: Option, + pub is_discoverable_app: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppReview { + pub status: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppScreenshot { + pub url: Option, + #[serde(alias = "file_id")] + pub file_id: Option, + #[serde(alias = "user_prompt")] + pub user_prompt: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AppMetadata { + pub review: Option, + pub categories: Option>, + pub sub_categories: Option>, + pub seo_description: Option, + pub screenshots: Option>, + pub developer: Option, + pub version: Option, + pub version_id: Option, + pub version_notes: Option, + pub first_party_type: Option, + pub first_party_requires_install: Option, + pub show_in_composer_when_unlinked: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - app metadata returned by app-list APIs. +pub struct AppInfo { + pub id: String, + pub name: String, + pub description: Option, + pub logo_url: Option, + pub logo_url_dark: Option, + pub distribution_channel: Option, + pub branding: Option, + pub app_metadata: Option, + pub labels: Option>, + pub install_url: Option, + #[serde(default)] + pub is_accessible: bool, + /// Whether this app is enabled in config.toml. + /// Example: + /// ```toml + /// [apps.bad_app] + /// enabled = false + /// ``` + #[serde(default = "default_enabled")] + pub is_enabled: bool, + #[serde(default)] + pub plugin_display_names: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - app metadata summary for plugin responses. +pub struct AppSummary { + pub id: String, + pub name: String, + pub description: Option, + pub install_url: Option, + pub needs_auth: bool, +} + +impl From for AppSummary { + fn from(value: AppInfo) -> Self { + Self { + id: value.id, + name: value.name, + description: value.description, + install_url: value.install_url, + needs_auth: false, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - app list response. +pub struct AppsListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// If None, there are no more items to return. + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - notification emitted when the app list changes. +pub struct AppListUpdatedNotification { + pub data: Vec, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/attestation.rs b/codex-rs/app-server-protocol/src/protocol/v2/attestation.rs new file mode 100644 index 0000000000..ef8828b580 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/attestation.rs @@ -0,0 +1,17 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Default)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AttestationGenerateParams {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AttestationGenerateResponse { + /// Opaque client attestation token. + pub token: String, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/collaboration_mode.rs b/codex-rs/app-server-protocol/src/protocol/v2/collaboration_mode.rs new file mode 100644 index 0000000000..b013bc13d4 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/collaboration_mode.rs @@ -0,0 +1,45 @@ +use codex_protocol::config_types::CollaborationModeMask as CoreCollaborationModeMask; +use codex_protocol::config_types::ModeKind; +use codex_protocol::openai_models::ReasoningEffort; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +/// EXPERIMENTAL - list collaboration mode presets. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CollaborationModeListParams {} + +/// EXPERIMENTAL - collaboration mode preset metadata for clients. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CollaborationModeMask { + pub name: String, + pub mode: Option, + pub model: Option, + #[serde(rename = "reasoning_effort")] + #[ts(rename = "reasoning_effort")] + pub reasoning_effort: Option>, +} + +impl From for CollaborationModeMask { + fn from(value: CoreCollaborationModeMask) -> Self { + Self { + name: value.name, + mode: value.mode, + model: value.model, + reasoning_effort: value.reasoning_effort, + } + } +} + +/// EXPERIMENTAL - collaboration mode presets response. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CollaborationModeListResponse { + pub data: Vec, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs b/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs new file mode 100644 index 0000000000..ff0cecf4f9 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/command_exec.rs @@ -0,0 +1,214 @@ +use super::PermissionProfile; +use super::SandboxPolicy; +use codex_experimental_api_macros::ExperimentalApi; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use std::path::PathBuf; +use ts_rs::TS; + +/// PTY size in character cells for `command/exec` PTY sessions. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecTerminalSize { + /// Terminal height in character cells. + pub rows: u16, + /// Terminal width in character cells. + pub cols: u16, +} + +/// Run a standalone command (argv vector) in the server sandbox without +/// creating a thread or turn. +/// +/// The final `command/exec` response is deferred until the process exits and is +/// sent only after all `command/exec/outputDelta` notifications for that +/// connection have been emitted. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecParams { + /// Command argv vector. Empty arrays are rejected. + pub command: Vec, + /// Optional client-supplied, connection-scoped process id. + /// + /// Required for `tty`, `streamStdin`, `streamStdoutStderr`, and follow-up + /// `command/exec/write`, `command/exec/resize`, and + /// `command/exec/terminate` calls. When omitted, buffered execution gets an + /// internal id that is not exposed to the client. + #[ts(optional = nullable)] + pub process_id: Option, + /// Enable PTY mode. + /// + /// This implies `streamStdin` and `streamStdoutStderr`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub tty: bool, + /// Allow follow-up `command/exec/write` requests to write stdin bytes. + /// + /// Requires a client-supplied `processId`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stream_stdin: bool, + /// Stream stdout/stderr via `command/exec/outputDelta` notifications. + /// + /// Streamed bytes are not duplicated into the final response and require a + /// client-supplied `processId`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stream_stdout_stderr: bool, + /// Optional per-stream stdout/stderr capture cap in bytes. + /// + /// When omitted, the server default applies. Cannot be combined with + /// `disableOutputCap`. + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub output_bytes_cap: Option, + /// Disable stdout/stderr capture truncation for this request. + /// + /// Cannot be combined with `outputBytesCap`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub disable_output_cap: bool, + /// Disable the timeout entirely for this request. + /// + /// Cannot be combined with `timeoutMs`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub disable_timeout: bool, + /// Optional timeout in milliseconds. + /// + /// When omitted, the server default applies. Cannot be combined with + /// `disableTimeout`. + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub timeout_ms: Option, + /// Optional working directory. Defaults to the server cwd. + #[ts(optional = nullable)] + pub cwd: Option, + /// Optional environment overrides merged into the server-computed + /// environment. + /// + /// Matching names override inherited values. Set a key to `null` to unset + /// an inherited variable. + #[ts(optional = nullable)] + pub env: Option>>, + /// Optional initial PTY size in character cells. Only valid when `tty` is + /// true. + #[ts(optional = nullable)] + pub size: Option, + /// Optional sandbox policy for this command. + /// + /// Uses the same shape as thread/turn execution sandbox configuration and + /// defaults to the user's configured policy when omitted. Cannot be + /// combined with `permissionProfile`. + #[ts(optional = nullable)] + pub sandbox_policy: Option, + /// Optional full permissions profile for this command. + /// + /// Defaults to the user's configured permissions when omitted. Cannot be + /// combined with `sandboxPolicy`. + #[experimental("command/exec.permissionProfile")] + #[ts(optional = nullable)] + pub permission_profile: Option, +} + +/// Final buffered result for `command/exec`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecResponse { + /// Process exit code. + pub exit_code: i32, + /// Buffered stdout capture. + /// + /// Empty when stdout was streamed via `command/exec/outputDelta`. + pub stdout: String, + /// Buffered stderr capture. + /// + /// Empty when stderr was streamed via `command/exec/outputDelta`. + pub stderr: String, +} + +/// Write stdin bytes to a running `command/exec` session, close stdin, or +/// both. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecWriteParams { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. + pub process_id: String, + /// Optional base64-encoded stdin bytes to write. + #[ts(optional = nullable)] + pub delta_base64: Option, + /// Close stdin after writing `deltaBase64`, if present. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub close_stdin: bool, +} + +/// Empty success response for `command/exec/write`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecWriteResponse {} + +/// Terminate a running `command/exec` session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecTerminateParams { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. + pub process_id: String, +} + +/// Empty success response for `command/exec/terminate`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecTerminateResponse {} + +/// Resize a running PTY-backed `command/exec` session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecResizeParams { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. + pub process_id: String, + /// New PTY size in character cells. + pub size: CommandExecTerminalSize, +} + +/// Empty success response for `command/exec/resize`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecResizeResponse {} + +/// Stream label for `command/exec/outputDelta` notifications. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CommandExecOutputStream { + /// stdout stream. PTY mode multiplexes terminal output here. + Stdout, + /// stderr stream. + Stderr, +} +/// Base64-encoded output chunk emitted for a streaming `command/exec` request. +/// +/// These notifications are connection-scoped. If the originating connection +/// closes, the server terminates the process. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecOutputDeltaNotification { + /// Client-supplied, connection-scoped `processId` from the original + /// `command/exec` request. + pub process_id: String, + /// Output stream for this chunk. + pub stream: CommandExecOutputStream, + /// Base64-encoded output bytes. + pub delta_base64: String, + /// `true` on the final streamed chunk for a stream when `outputBytesCap` + /// truncated later output on that stream. + pub cap_reached: bool, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs new file mode 100644 index 0000000000..2c0e2df8e5 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -0,0 +1,712 @@ +use super::ApprovalsReviewer; +use super::AskForApproval; +use super::SandboxMode; +use super::shared::default_enabled; +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::config_types::ForcedLoginMethod; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::Verbosity; +use codex_protocol::config_types::WebSearchMode; +use codex_protocol::config_types::WebSearchToolConfig; +use codex_protocol::openai_models::ReasoningEffort; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ConfigLayerSource { + /// Managed preferences layer delivered by MDM (macOS only). + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Mdm { + domain: String, + key: String, + }, + + /// Managed config layer from a file (usually `managed_config.toml`). + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + System { + /// This is the path to the system config.toml file, though it is not + /// guaranteed to exist. + file: AbsolutePathBuf, + }, + + /// User config layer from $CODEX_HOME/config.toml. This layer is special + /// in that it is expected to be: + /// - writable by the user + /// - generally outside the workspace directory + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + User { + /// This is the path to the user's config.toml file, though it is not + /// guaranteed to exist. + file: AbsolutePathBuf, + }, + + /// Path to a .codex/ folder within a project. There could be multiple of + /// these between `cwd` and the project/repo root. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Project { + dot_codex_folder: AbsolutePathBuf, + }, + + /// Session-layer overrides supplied via `-c`/`--config`. + SessionFlags, + + /// `managed_config.toml` was designed to be a config that was loaded + /// as the last layer on top of everything else. This scheme did not quite + /// work out as intended, but we keep this variant as a "best effort" while + /// we phase out `managed_config.toml` in favor of `requirements.toml`. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + LegacyManagedConfigTomlFromFile { + file: AbsolutePathBuf, + }, + + LegacyManagedConfigTomlFromMdm, +} + +impl ConfigLayerSource { + /// A settings from a layer with a higher precedence will override a setting + /// from a layer with a lower precedence. + pub fn precedence(&self) -> i16 { + match self { + ConfigLayerSource::Mdm { .. } => 0, + ConfigLayerSource::System { .. } => 10, + ConfigLayerSource::User { .. } => 20, + ConfigLayerSource::Project { .. } => 25, + ConfigLayerSource::SessionFlags => 30, + ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => 40, + ConfigLayerSource::LegacyManagedConfigTomlFromMdm => 50, + } + } +} + +/// Compares [ConfigLayerSource] by precedence, so `A < B` means settings from +/// layer `A` will be overridden by settings from layer `B`. +impl PartialOrd for ConfigLayerSource { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.precedence().cmp(&other.precedence())) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct SandboxWorkspaceWrite { + #[serde(default)] + pub writable_roots: Vec, + #[serde(default)] + pub network_access: bool, + #[serde(default)] + pub exclude_tmpdir_env_var: bool, + #[serde(default)] + pub exclude_slash_tmp: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct ToolsV2 { + pub web_search: Option, + pub view_image: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct ProfileV2 { + pub model: Option, + pub model_provider: Option, + #[experimental(nested)] + pub approval_policy: Option, + /// [UNSTABLE] Optional profile-level override for where approval requests + /// are routed for review. If omitted, the enclosing config default is + /// used. + #[experimental("config/read.approvalsReviewer")] + pub approvals_reviewer: Option, + pub service_tier: Option, + pub model_reasoning_effort: Option, + pub model_reasoning_summary: Option, + pub model_verbosity: Option, + pub web_search: Option, + pub tools: Option, + pub chatgpt_base_url: Option, + #[serde(default, flatten)] + pub additional: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AnalyticsConfig { + pub enabled: Option, + #[serde(default, flatten)] + pub additional: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum AppToolApproval { + Auto, + Prompt, + Approve, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppsDefaultConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, + #[serde(default = "default_enabled")] + pub destructive_enabled: bool, + #[serde(default = "default_enabled")] + pub open_world_enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppToolConfig { + pub enabled: Option, + pub approval_mode: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppToolsConfig { + #[serde(default, flatten)] + pub tools: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, + pub destructive_enabled: Option, + pub open_world_enabled: Option, + pub default_tools_approval_mode: Option, + pub default_tools_enabled: Option, + pub tools: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct AppsConfig { + #[serde(default, rename = "_default")] + pub default: Option, + #[serde(default, flatten)] + pub apps: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub struct Config { + pub model: Option, + pub review_model: Option, + pub model_context_window: Option, + pub model_auto_compact_token_limit: Option, + pub model_provider: Option, + #[experimental(nested)] + pub approval_policy: Option, + /// [UNSTABLE] Optional default for where approval requests are routed for + /// review. + #[experimental("config/read.approvalsReviewer")] + pub approvals_reviewer: Option, + pub sandbox_mode: Option, + pub sandbox_workspace_write: Option, + pub forced_chatgpt_workspace_id: Option, + pub forced_login_method: Option, + pub web_search: Option, + pub tools: Option, + pub profile: Option, + #[experimental(nested)] + #[serde(default)] + pub profiles: HashMap, + pub instructions: Option, + pub developer_instructions: Option, + pub compact_prompt: Option, + pub model_reasoning_effort: Option, + pub model_reasoning_summary: Option, + pub model_verbosity: Option, + pub service_tier: Option, + pub analytics: Option, + #[experimental("config/read.apps")] + #[serde(default)] + pub apps: Option, + #[serde(default, flatten)] + pub additional: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigLayerMetadata { + pub name: ConfigLayerSource, + pub version: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigLayer { + pub name: ConfigLayerSource, + pub version: String, + pub config: JsonValue, + #[serde(skip_serializing_if = "Option::is_none")] + pub disabled_reason: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum MergeStrategy { + Replace, + Upsert, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum WriteStatus { + Ok, + OkOverridden, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct OverriddenMetadata { + pub message: String, + pub overriding_layer: ConfigLayerMetadata, + pub effective_value: JsonValue, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigWriteResponse { + pub status: WriteStatus, + pub version: String, + /// Canonical path to the config file that was written. + pub file_path: AbsolutePathBuf, + pub overridden_metadata: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ConfigWriteErrorCode { + ConfigLayerReadonly, + ConfigVersionConflict, + ConfigValidationError, + ConfigPathNotFound, + ConfigSchemaUnknownKey, + UserLayerNotFound, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigReadParams { + #[serde(default)] + pub include_layers: bool, + /// Optional working directory to resolve project config layers. If specified, + /// return the effective config as seen from that directory (i.e., including any + /// project layers between `cwd` and the project/repo root). + #[ts(optional = nullable)] + pub cwd: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigReadResponse { + #[experimental(nested)] + pub config: Config, + pub origins: HashMap, + #[serde(skip_serializing_if = "Option::is_none")] + pub layers: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigRequirements { + #[experimental(nested)] + pub allowed_approval_policies: Option>, + #[experimental("configRequirements/read.allowedApprovalsReviewers")] + pub allowed_approvals_reviewers: Option>, + pub allowed_sandbox_modes: Option>, + pub allowed_web_search_modes: Option>, + pub allow_managed_hooks_only: Option, + pub feature_requirements: Option>, + #[experimental("configRequirements/read.hooks")] + pub hooks: Option, + pub enforce_residency: Option, + #[experimental("configRequirements/read.network")] + pub network: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ManagedHooksRequirements { + pub managed_dir: Option, + pub windows_managed_dir: Option, + #[serde(rename = "PreToolUse")] + #[ts(rename = "PreToolUse")] + pub pre_tool_use: Vec, + #[serde(rename = "PermissionRequest")] + #[ts(rename = "PermissionRequest")] + pub permission_request: Vec, + #[serde(rename = "PostToolUse")] + #[ts(rename = "PostToolUse")] + pub post_tool_use: Vec, + #[serde(rename = "PreCompact")] + #[ts(rename = "PreCompact")] + pub pre_compact: Vec, + #[serde(rename = "PostCompact")] + #[ts(rename = "PostCompact")] + pub post_compact: Vec, + #[serde(rename = "SessionStart")] + #[ts(rename = "SessionStart")] + pub session_start: Vec, + #[serde(rename = "UserPromptSubmit")] + #[ts(rename = "UserPromptSubmit")] + pub user_prompt_submit: Vec, + #[serde(rename = "Stop")] + #[ts(rename = "Stop")] + pub stop: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfiguredHookMatcherGroup { + pub matcher: Option, + pub hooks: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type")] +#[ts(tag = "type", export_to = "v2/")] +pub enum ConfiguredHookHandler { + #[serde(rename = "command")] + #[ts(rename = "command")] + Command { + command: String, + #[serde(rename = "commandWindows")] + #[ts(rename = "commandWindows")] + command_windows: Option, + #[serde(rename = "timeoutSec")] + #[ts(rename = "timeoutSec")] + timeout_sec: Option, + r#async: bool, + #[serde(rename = "statusMessage")] + #[ts(rename = "statusMessage")] + status_message: Option, + }, + #[serde(rename = "prompt")] + #[ts(rename = "prompt")] + Prompt {}, + #[serde(rename = "agent")] + #[ts(rename = "agent")] + Agent {}, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct NetworkRequirements { + pub enabled: Option, + pub http_port: Option, + pub socks_port: Option, + pub allow_upstream_proxy: Option, + pub dangerously_allow_non_loopback_proxy: Option, + pub dangerously_allow_all_unix_sockets: Option, + /// Canonical network permission map for `experimental_network`. + pub domains: Option>, + /// When true, only managed allowlist entries are respected while managed + /// network enforcement is active. + pub managed_allowed_domains_only: Option, + /// Legacy compatibility view derived from `domains`. + pub allowed_domains: Option>, + /// Legacy compatibility view derived from `domains`. + pub denied_domains: Option>, + /// Canonical unix socket permission map for `experimental_network`. + pub unix_sockets: Option>, + /// Legacy compatibility view derived from `unix_sockets`. + pub allow_unix_sockets: Option>, + pub allow_local_binding: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum NetworkDomainPermission { + Allow, + Deny, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum NetworkUnixSocketPermission { + Allow, + None, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ResidencyRequirement { + Us, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigRequirementsReadResponse { + /// Null if no requirements are configured (e.g. no requirements.toml/MDM entries). + #[experimental(nested)] + pub requirements: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum ExternalAgentConfigMigrationItemType { + #[serde(rename = "AGENTS_MD")] + #[ts(rename = "AGENTS_MD")] + AgentsMd, + #[serde(rename = "CONFIG")] + #[ts(rename = "CONFIG")] + Config, + #[serde(rename = "SKILLS")] + #[ts(rename = "SKILLS")] + Skills, + #[serde(rename = "PLUGINS")] + #[ts(rename = "PLUGINS")] + Plugins, + #[serde(rename = "MCP_SERVER_CONFIG")] + #[ts(rename = "MCP_SERVER_CONFIG")] + McpServerConfig, + #[serde(rename = "SUBAGENTS")] + #[ts(rename = "SUBAGENTS")] + Subagents, + #[serde(rename = "HOOKS")] + #[ts(rename = "HOOKS")] + Hooks, + #[serde(rename = "COMMANDS")] + #[ts(rename = "COMMANDS")] + Commands, + #[serde(rename = "SESSIONS")] + #[ts(rename = "SESSIONS")] + Sessions, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginsMigration { + #[serde(rename = "marketplaceName")] + #[ts(rename = "marketplaceName")] + pub marketplace_name: String, + #[serde(rename = "pluginNames")] + #[ts(rename = "pluginNames")] + pub plugin_names: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SessionMigration { + pub path: PathBuf, + pub cwd: PathBuf, + pub title: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerMigration { + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookMigration { + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SubagentMigration { + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandMigration { + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MigrationDetails { + #[serde(default)] + pub plugins: Vec, + #[serde(default)] + pub sessions: Vec, + #[serde(default)] + pub mcp_servers: Vec, + #[serde(default)] + pub hooks: Vec, + #[serde(default)] + pub subagents: Vec, + #[serde(default)] + pub commands: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigMigrationItem { + pub item_type: ExternalAgentConfigMigrationItemType, + pub description: String, + /// Null or empty means home-scoped migration; non-empty means repo-scoped migration. + pub cwd: Option, + pub details: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigDetectResponse { + pub items: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigDetectParams { + /// If true, include detection under the user's home (~/.claude, ~/.codex, etc.). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub include_home: bool, + /// Zero or more working directories to include for repo-scoped detection. + #[ts(optional = nullable)] + pub cwds: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigImportParams { + pub migration_items: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigImportResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExternalAgentConfigImportCompletedNotification {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigValueWriteParams { + pub key_path: String, + pub value: JsonValue, + pub merge_strategy: MergeStrategy, + /// Path to the config file to write; defaults to the user's `config.toml` when omitted. + #[ts(optional = nullable)] + pub file_path: Option, + #[ts(optional = nullable)] + pub expected_version: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigBatchWriteParams { + pub edits: Vec, + /// Path to the config file to write; defaults to the user's `config.toml` when omitted. + #[ts(optional = nullable)] + pub file_path: Option, + #[ts(optional = nullable)] + pub expected_version: Option, + /// When true, hot-reload the updated user config into all loaded threads after writing. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub reload_user_config: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigEdit { + pub key_path: String, + pub value: JsonValue, + pub merge_strategy: MergeStrategy, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TextPosition { + /// 1-based line number. + pub line: usize, + /// 1-based column number (in Unicode scalar values). + pub column: usize, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TextRange { + pub start: TextPosition, + pub end: TextPosition, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ConfigWarningNotification { + /// Concise summary of the warning. + pub summary: String, + /// Optional extra guidance or error details. + pub details: Option, + /// Optional path to the config file that triggered the warning. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub path: Option, + /// Optional range for the error location inside the config file. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub range: Option, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/environment.rs b/codex-rs/app-server-protocol/src/protocol/v2/environment.rs new file mode 100644 index 0000000000..294ae736fd --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/environment.rs @@ -0,0 +1,17 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct EnvironmentAddParams { + pub environment_id: String, + pub exec_server_url: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct EnvironmentAddResponse {} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs b/codex-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs new file mode 100644 index 0000000000..6adc21b6ef --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/experimental_feature.rs @@ -0,0 +1,85 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + #[ts(optional = nullable)] + pub limit: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ExperimentalFeatureStage { + /// Feature is available for user testing and feedback. + Beta, + /// Feature is still being built and not ready for broad use. + UnderDevelopment, + /// Feature is production-ready. + Stable, + /// Feature is deprecated and should be avoided. + Deprecated, + /// Feature flag is retained only for backwards compatibility. + Removed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeature { + /// Stable key used in config.toml and CLI flag toggles. + pub name: String, + /// Lifecycle stage of this feature flag. + pub stage: ExperimentalFeatureStage, + /// User-facing display name shown in the experimental features UI. + /// Null when this feature is not in beta. + pub display_name: Option, + /// Short summary describing what the feature does. + /// Null when this feature is not in beta. + pub description: Option, + /// Announcement copy shown to users when the feature is introduced. + /// Null when this feature is not in beta. + pub announcement: Option, + /// Whether this feature is currently enabled in the loaded config. + pub enabled: bool, + /// Whether this feature is enabled by default. + pub default_enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// If None, there are no more items to return. + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureEnablementSetParams { + /// Process-wide runtime feature enablement keyed by canonical feature name. + /// + /// Only named features are updated. Omitted features are left unchanged. + /// Send an empty map for a no-op. + pub enablement: BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureEnablementSetResponse { + /// Feature enablement entries updated by this request. + pub enablement: BTreeMap, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/feedback.rs b/codex-rs/app-server-protocol/src/protocol/v2/feedback.rs new file mode 100644 index 0000000000..aaf966a4bf --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/feedback.rs @@ -0,0 +1,29 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::BTreeMap; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FeedbackUploadParams { + pub classification: String, + #[ts(optional = nullable)] + pub reason: Option, + #[ts(optional = nullable)] + pub thread_id: Option, + pub include_logs: bool, + #[ts(optional = nullable)] + pub extra_log_files: Option>, + #[ts(optional = nullable)] + pub tags: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FeedbackUploadResponse { + pub thread_id: String, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/fs.rs b/codex-rs/app-server-protocol/src/protocol/v2/fs.rs new file mode 100644 index 0000000000..0132c6b284 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/fs.rs @@ -0,0 +1,204 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +/// Read a file from the host filesystem. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsReadFileParams { + /// Absolute path to read. + pub path: AbsolutePathBuf, +} + +/// Base64-encoded file contents returned by `fs/readFile`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsReadFileResponse { + /// File contents encoded as base64. + pub data_base64: String, +} + +/// Write a file on the host filesystem. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsWriteFileParams { + /// Absolute path to write. + pub path: AbsolutePathBuf, + /// File contents encoded as base64. + pub data_base64: String, +} + +/// Successful response for `fs/writeFile`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsWriteFileResponse {} + +/// Create a directory on the host filesystem. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsCreateDirectoryParams { + /// Absolute directory path to create. + pub path: AbsolutePathBuf, + /// Whether parent directories should also be created. Defaults to `true`. + #[ts(optional = nullable)] + pub recursive: Option, +} + +/// Successful response for `fs/createDirectory`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsCreateDirectoryResponse {} + +/// Request metadata for an absolute path. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsGetMetadataParams { + /// Absolute path to inspect. + pub path: AbsolutePathBuf, +} + +/// Metadata returned by `fs/getMetadata`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsGetMetadataResponse { + /// Whether the path resolves to a directory. + pub is_directory: bool, + /// Whether the path resolves to a regular file. + pub is_file: bool, + /// Whether the path itself is a symbolic link. + pub is_symlink: bool, + /// File creation time in Unix milliseconds when available, otherwise `0`. + #[ts(type = "number")] + pub created_at_ms: i64, + /// File modification time in Unix milliseconds when available, otherwise `0`. + #[ts(type = "number")] + pub modified_at_ms: i64, +} + +/// List direct child names for a directory. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsReadDirectoryParams { + /// Absolute directory path to read. + pub path: AbsolutePathBuf, +} + +/// A directory entry returned by `fs/readDirectory`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsReadDirectoryEntry { + /// Direct child entry name only, not an absolute or relative path. + pub file_name: String, + /// Whether this entry resolves to a directory. + pub is_directory: bool, + /// Whether this entry resolves to a regular file. + pub is_file: bool, +} + +/// Directory entries returned by `fs/readDirectory`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsReadDirectoryResponse { + /// Direct child entries in the requested directory. + pub entries: Vec, +} + +/// Remove a file or directory tree from the host filesystem. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsRemoveParams { + /// Absolute path to remove. + pub path: AbsolutePathBuf, + /// Whether directory removal should recurse. Defaults to `true`. + #[ts(optional = nullable)] + pub recursive: Option, + /// Whether missing paths should be ignored. Defaults to `true`. + #[ts(optional = nullable)] + pub force: Option, +} + +/// Successful response for `fs/remove`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsRemoveResponse {} + +/// Copy a file or directory tree on the host filesystem. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsCopyParams { + /// Absolute source path. + pub source_path: AbsolutePathBuf, + /// Absolute destination path. + pub destination_path: AbsolutePathBuf, + /// Required for directory copies; ignored for file copies. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub recursive: bool, +} + +/// Successful response for `fs/copy`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsCopyResponse {} + +/// Start filesystem watch notifications for an absolute path. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsWatchParams { + /// Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`. + pub watch_id: String, + /// Absolute file or directory path to watch. + pub path: AbsolutePathBuf, +} + +/// Successful response for `fs/watch`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsWatchResponse { + /// Canonicalized path associated with the watch. + pub path: AbsolutePathBuf, +} + +/// Stop filesystem watch notifications for a prior `fs/watch`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsUnwatchParams { + /// Watch identifier previously provided to `fs/watch`. + pub watch_id: String, +} + +/// Successful response for `fs/unwatch`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsUnwatchResponse {} + +/// Filesystem watch notification emitted for `fs/watch` subscribers. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsChangedNotification { + /// Watch identifier previously provided to `fs/watch`. + pub watch_id: String, + /// File or directory paths associated with this event. + pub changed_paths: Vec, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs new file mode 100644 index 0000000000..4a07bd495b --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs @@ -0,0 +1,154 @@ +use super::shared::v2_enum_from_core; +use codex_protocol::protocol::HookEventName as CoreHookEventName; +use codex_protocol::protocol::HookExecutionMode as CoreHookExecutionMode; +use codex_protocol::protocol::HookHandlerType as CoreHookHandlerType; +use codex_protocol::protocol::HookOutputEntry as CoreHookOutputEntry; +use codex_protocol::protocol::HookOutputEntryKind as CoreHookOutputEntryKind; +use codex_protocol::protocol::HookRunStatus as CoreHookRunStatus; +use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary; +use codex_protocol::protocol::HookScope as CoreHookScope; +use codex_protocol::protocol::HookSource as CoreHookSource; +use codex_protocol::protocol::HookTrustStatus as CoreHookTrustStatus; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +v2_enum_from_core!( + pub enum HookEventName from CoreHookEventName { + PreToolUse, PermissionRequest, PostToolUse, PreCompact, PostCompact, SessionStart, UserPromptSubmit, Stop + } +); + +v2_enum_from_core!( + pub enum HookHandlerType from CoreHookHandlerType { + Command, Prompt, Agent + } +); + +v2_enum_from_core!( + pub enum HookExecutionMode from CoreHookExecutionMode { + Sync, Async + } +); + +v2_enum_from_core!( + pub enum HookScope from CoreHookScope { + Thread, Turn + } +); + +v2_enum_from_core!( + pub enum HookSource from CoreHookSource { + System, + User, + Project, + Mdm, + SessionFlags, + Plugin, + CloudRequirements, + LegacyManagedConfigFile, + LegacyManagedConfigMdm, + Unknown, + } +); + +v2_enum_from_core!( + pub enum HookTrustStatus from CoreHookTrustStatus { + Managed, Untrusted, Trusted, Modified + } +); + +fn default_hook_source() -> HookSource { + HookSource::Unknown +} + +v2_enum_from_core!( + pub enum HookRunStatus from CoreHookRunStatus { + Running, Completed, Failed, Blocked, Stopped + } +); + +v2_enum_from_core!( + pub enum HookOutputEntryKind from CoreHookOutputEntryKind { + Warning, Stop, Feedback, Context, Error + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookOutputEntry { + pub kind: HookOutputEntryKind, + pub text: String, +} + +impl From for HookOutputEntry { + fn from(value: CoreHookOutputEntry) -> Self { + Self { + kind: value.kind.into(), + text: value.text, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookRunSummary { + pub id: String, + pub event_name: HookEventName, + pub handler_type: HookHandlerType, + pub execution_mode: HookExecutionMode, + pub scope: HookScope, + pub source_path: AbsolutePathBuf, + #[serde(default = "default_hook_source")] + pub source: HookSource, + pub display_order: i64, + pub status: HookRunStatus, + pub status_message: Option, + pub started_at: i64, + pub completed_at: Option, + pub duration_ms: Option, + pub entries: Vec, +} + +impl From for HookRunSummary { + fn from(value: CoreHookRunSummary) -> Self { + Self { + id: value.id, + event_name: value.event_name.into(), + handler_type: value.handler_type.into(), + execution_mode: value.execution_mode.into(), + scope: value.scope.into(), + source_path: value.source_path, + source: value.source.into(), + display_order: value.display_order, + status: value.status.into(), + status_message: value.status_message, + started_at: value.started_at, + completed_at: value.completed_at, + duration_ms: value.duration_ms, + entries: value.entries.into_iter().map(Into::into).collect(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookStartedNotification { + pub thread_id: String, + pub turn_id: Option, + pub run: HookRunSummary, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookCompletedNotification { + pub thread_id: String, + pub turn_id: Option, + pub run: HookRunSummary, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs new file mode 100644 index 0000000000..0e22c48590 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -0,0 +1,1447 @@ +use super::AdditionalPermissionProfile; +use super::ExecPolicyAmendment; +use super::McpToolCallError; +use super::McpToolCallResult; +use super::NetworkApprovalContext; +use super::NetworkApprovalProtocol; +use super::NetworkPolicyAmendment; +use super::RequestPermissionProfile; +use super::UserInput; +use super::shared::v2_enum_from_core; +use crate::protocol::item_builders::convert_patch_changes; +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::approvals::GuardianAssessmentAction as CoreGuardianAssessmentAction; +use codex_protocol::approvals::GuardianAssessmentDecisionSource as CoreGuardianAssessmentDecisionSource; +use codex_protocol::approvals::GuardianCommandSource as CoreGuardianCommandSource; +use codex_protocol::items::AgentMessageContent as CoreAgentMessageContent; +use codex_protocol::items::McpToolCallStatus as CoreMcpToolCallStatus; +use codex_protocol::items::TurnItem as CoreTurnItem; +use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; +use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; +use codex_protocol::models::MessagePhase; +use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::parse_command::ParsedCommand as CoreParsedCommand; +use codex_protocol::protocol::AgentStatus as CoreAgentStatus; +use codex_protocol::protocol::ExecCommandSource as CoreExecCommandSource; +use codex_protocol::protocol::ExecCommandStatus as CoreExecCommandStatus; +use codex_protocol::protocol::GuardianRiskLevel as CoreGuardianRiskLevel; +use codex_protocol::protocol::GuardianUserAuthorization as CoreGuardianUserAuthorization; +use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; +use codex_protocol::protocol::ReviewDecision as CoreReviewDecision; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use serde_with::serde_as; +use std::collections::HashMap; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CommandExecutionApprovalDecision { + /// User approved the command. + Accept, + /// User approved the command and future prompts in the same session-scoped + /// approval cache should run without prompting. + AcceptForSession, + /// User approved the command, and wants to apply the proposed execpolicy amendment so future + /// matching commands can run without prompting. + AcceptWithExecpolicyAmendment { + execpolicy_amendment: ExecPolicyAmendment, + }, + /// User chose a persistent network policy rule (allow/deny) for this host. + ApplyNetworkPolicyAmendment { + network_policy_amendment: NetworkPolicyAmendment, + }, + /// User denied the command. The agent will continue the turn. + Decline, + /// User denied the command. The turn will also be immediately interrupted. + Cancel, +} + +impl From for CommandExecutionApprovalDecision { + fn from(value: CoreReviewDecision) -> Self { + match value { + CoreReviewDecision::Approved => Self::Accept, + CoreReviewDecision::ApprovedExecpolicyAmendment { + proposed_execpolicy_amendment, + } => Self::AcceptWithExecpolicyAmendment { + execpolicy_amendment: proposed_execpolicy_amendment.into(), + }, + CoreReviewDecision::ApprovedForSession => Self::AcceptForSession, + CoreReviewDecision::NetworkPolicyAmendment { + network_policy_amendment, + } => Self::ApplyNetworkPolicyAmendment { + network_policy_amendment: network_policy_amendment.into(), + }, + CoreReviewDecision::Abort => Self::Cancel, + CoreReviewDecision::Denied => Self::Decline, + CoreReviewDecision::TimedOut => Self::Decline, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum FileChangeApprovalDecision { + /// User approved the file changes. + Accept, + /// User approved the file changes and future changes to the same files should run without prompting. + AcceptForSession, + /// User denied the file changes. The agent will continue the turn. + Decline, + /// User denied the file changes. The turn will also be immediately interrupted. + Cancel, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum CommandAction { + Read { + command: String, + name: String, + path: AbsolutePathBuf, + }, + ListFiles { + command: String, + path: Option, + }, + Search { + command: String, + query: Option, + path: Option, + }, + Unknown { + command: String, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MemoryCitation { + pub entries: Vec, + pub thread_ids: Vec, +} + +impl From for MemoryCitation { + fn from(value: CoreMemoryCitation) -> Self { + Self { + entries: value.entries.into_iter().map(Into::into).collect(), + thread_ids: value.rollout_ids, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MemoryCitationEntry { + pub path: String, + pub line_start: u32, + pub line_end: u32, + pub note: String, +} + +impl From for MemoryCitationEntry { + fn from(value: CoreMemoryCitationEntry) -> Self { + Self { + path: value.path, + line_start: value.line_start, + line_end: value.line_end, + note: value.note, + } + } +} + +impl CommandAction { + pub fn into_core(self) -> CoreParsedCommand { + match self { + CommandAction::Read { + command: cmd, + name, + path, + } => CoreParsedCommand::Read { + cmd, + name, + path: path.into_path_buf(), + }, + CommandAction::ListFiles { command: cmd, path } => { + CoreParsedCommand::ListFiles { cmd, path } + } + CommandAction::Search { + command: cmd, + query, + path, + } => CoreParsedCommand::Search { cmd, query, path }, + CommandAction::Unknown { command: cmd } => CoreParsedCommand::Unknown { cmd }, + } + } + + pub fn from_core_with_cwd(value: CoreParsedCommand, cwd: &AbsolutePathBuf) -> Self { + match value { + CoreParsedCommand::Read { cmd, name, path } => CommandAction::Read { + command: cmd, + name, + path: cwd.join(path), + }, + CoreParsedCommand::ListFiles { cmd, path } => { + CommandAction::ListFiles { command: cmd, path } + } + CoreParsedCommand::Search { cmd, query, path } => CommandAction::Search { + command: cmd, + query, + path, + }, + CoreParsedCommand::Unknown { cmd } => CommandAction::Unknown { command: cmd }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ThreadItem { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + UserMessage { id: String, content: Vec }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + HookPrompt { + id: String, + fragments: Vec, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + AgentMessage { + id: String, + text: String, + #[serde(default)] + phase: Option, + #[serde(default)] + memory_citation: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + /// EXPERIMENTAL - proposed plan item content. The completed plan item is + /// authoritative and may not match the concatenation of `PlanDelta` text. + Plan { id: String, text: String }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Reasoning { + id: String, + #[serde(default)] + summary: Vec, + #[serde(default)] + content: Vec, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + CommandExecution { + id: String, + /// The command to be executed. + command: String, + /// The command's working directory. + cwd: AbsolutePathBuf, + /// Identifier for the underlying PTY process (when available). + process_id: Option, + #[serde(default)] + source: CommandExecutionSource, + status: CommandExecutionStatus, + /// A best-effort parsing of the command to understand the action(s) it will perform. + /// This returns a list of CommandAction objects because a single shell command may + /// be composed of many commands piped together. + command_actions: Vec, + /// The command's output, aggregated from stdout and stderr. + aggregated_output: Option, + /// The command's exit code. + exit_code: Option, + /// The duration of the command execution in milliseconds. + #[ts(type = "number | null")] + duration_ms: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + FileChange { + id: String, + changes: Vec, + status: PatchApplyStatus, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + McpToolCall { + id: String, + server: String, + tool: String, + status: McpToolCallStatus, + arguments: JsonValue, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + mcp_app_resource_uri: Option, + result: Option>, + error: Option, + /// The duration of the MCP tool call in milliseconds. + #[ts(type = "number | null")] + duration_ms: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + DynamicToolCall { + id: String, + namespace: Option, + tool: String, + arguments: JsonValue, + status: DynamicToolCallStatus, + content_items: Option>, + success: Option, + /// The duration of the dynamic tool call in milliseconds. + #[ts(type = "number | null")] + duration_ms: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + CollabAgentToolCall { + /// Unique identifier for this collab tool call. + id: String, + /// Name of the collab tool that was invoked. + tool: CollabAgentTool, + /// Current status of the collab tool call. + status: CollabAgentToolCallStatus, + /// Thread ID of the agent issuing the collab request. + sender_thread_id: String, + /// Thread ID of the receiving agent, when applicable. In case of spawn operation, + /// this corresponds to the newly spawned agent. + receiver_thread_ids: Vec, + /// Prompt text sent as part of the collab tool call, when available. + prompt: Option, + /// Model requested for the spawned agent, when applicable. + model: Option, + /// Reasoning effort requested for the spawned agent, when applicable. + reasoning_effort: Option, + /// Last known status of the target agents, when available. + agents_states: HashMap, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + WebSearch { + id: String, + query: String, + action: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ImageView { id: String, path: AbsolutePathBuf }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ImageGeneration { + id: String, + status: String, + revised_prompt: Option, + result: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + saved_path: Option, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + EnteredReviewMode { id: String, review: String }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ExitedReviewMode { id: String, review: String }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ContextCompaction { id: String }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub struct HookPromptFragment { + pub text: String, + pub hook_run_id: String, +} + +impl ThreadItem { + pub fn id(&self) -> &str { + match self { + ThreadItem::UserMessage { id, .. } + | ThreadItem::HookPrompt { id, .. } + | ThreadItem::AgentMessage { id, .. } + | ThreadItem::Plan { id, .. } + | ThreadItem::Reasoning { id, .. } + | ThreadItem::CommandExecution { id, .. } + | ThreadItem::FileChange { id, .. } + | ThreadItem::McpToolCall { id, .. } + | ThreadItem::DynamicToolCall { id, .. } + | ThreadItem::CollabAgentToolCall { id, .. } + | ThreadItem::WebSearch { id, .. } + | ThreadItem::ImageView { id, .. } + | ThreadItem::ImageGeneration { id, .. } + | ThreadItem::EnteredReviewMode { id, .. } + | ThreadItem::ExitedReviewMode { id, .. } + | ThreadItem::ContextCompaction { id, .. } => id, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Lifecycle state for an approval auto-review. +pub enum GuardianApprovalReviewStatus { + InProgress, + Approved, + Denied, + TimedOut, + Aborted, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Source that produced a terminal approval auto-review decision. +pub enum AutoReviewDecisionSource { + Agent, +} + +impl From for AutoReviewDecisionSource { + fn from(value: CoreGuardianAssessmentDecisionSource) -> Self { + match value { + CoreGuardianAssessmentDecisionSource::Agent => Self::Agent, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Risk level assigned by approval auto-review. +pub enum GuardianRiskLevel { + Low, + Medium, + High, + Critical, +} + +impl From for GuardianRiskLevel { + fn from(value: CoreGuardianRiskLevel) -> Self { + match value { + CoreGuardianRiskLevel::Low => Self::Low, + CoreGuardianRiskLevel::Medium => Self::Medium, + CoreGuardianRiskLevel::High => Self::High, + CoreGuardianRiskLevel::Critical => Self::Critical, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Authorization level assigned by approval auto-review. +pub enum GuardianUserAuthorization { + Unknown, + Low, + Medium, + High, +} + +impl From for GuardianUserAuthorization { + fn from(value: CoreGuardianUserAuthorization) -> Self { + match value { + CoreGuardianUserAuthorization::Unknown => Self::Unknown, + CoreGuardianUserAuthorization::Low => Self::Low, + CoreGuardianUserAuthorization::Medium => Self::Medium, + CoreGuardianUserAuthorization::High => Self::High, + } + } +} + +/// [UNSTABLE] Temporary approval auto-review payload used by +/// `item/autoApprovalReview/*` notifications. This shape is expected to change +/// soon. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianApprovalReview { + pub status: GuardianApprovalReviewStatus, + pub risk_level: Option, + pub user_authorization: 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: AbsolutePathBuf, +} + +#[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: AbsolutePathBuf, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianApplyPatchReviewAction { + pub cwd: AbsolutePathBuf, + 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, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianRequestPermissionsReviewAction { + pub reason: Option, + pub permissions: RequestPermissionProfile, +} + +#[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: AbsolutePathBuf, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Execve { + source: GuardianCommandSource, + program: String, + argv: Vec, + cwd: AbsolutePathBuf, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ApplyPatch { + cwd: AbsolutePathBuf, + 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, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + RequestPermissions { + reason: Option, + permissions: RequestPermissionProfile, + }, +} + +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, + }, + CoreGuardianAssessmentAction::RequestPermissions { + reason, + permissions, + } => Self::RequestPermissions { + reason, + permissions: permissions.into(), + }, + } + } +} + +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, + }, + GuardianApprovalReviewAction::RequestPermissions { + reason, + permissions, + } => Self::RequestPermissions { + reason, + permissions: permissions.into(), + }, + } + } +} + +#[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 WebSearchAction { + Search { + query: Option, + queries: Option>, + }, + OpenPage { + url: Option, + }, + FindInPage { + url: Option, + pattern: Option, + }, + #[serde(other)] + Other, +} + +impl From for WebSearchAction { + fn from(value: codex_protocol::models::WebSearchAction) -> Self { + match value { + codex_protocol::models::WebSearchAction::Search { query, queries } => { + WebSearchAction::Search { query, queries } + } + codex_protocol::models::WebSearchAction::OpenPage { url } => { + WebSearchAction::OpenPage { url } + } + codex_protocol::models::WebSearchAction::FindInPage { url, pattern } => { + WebSearchAction::FindInPage { url, pattern } + } + codex_protocol::models::WebSearchAction::Other => WebSearchAction::Other, + } + } +} + +impl From for ThreadItem { + fn from(value: CoreTurnItem) -> Self { + match value { + CoreTurnItem::UserMessage(user) => ThreadItem::UserMessage { + id: user.id, + content: user.content.into_iter().map(UserInput::from).collect(), + }, + CoreTurnItem::HookPrompt(hook_prompt) => ThreadItem::HookPrompt { + id: hook_prompt.id, + fragments: hook_prompt + .fragments + .into_iter() + .map(HookPromptFragment::from) + .collect(), + }, + CoreTurnItem::AgentMessage(agent) => { + let text = agent + .content + .into_iter() + .map(|entry| match entry { + CoreAgentMessageContent::Text { text } => text, + }) + .collect::(); + ThreadItem::AgentMessage { + id: agent.id, + text, + phase: agent.phase, + memory_citation: agent.memory_citation.map(Into::into), + } + } + CoreTurnItem::Plan(plan) => ThreadItem::Plan { + id: plan.id, + text: plan.text, + }, + CoreTurnItem::Reasoning(reasoning) => ThreadItem::Reasoning { + id: reasoning.id, + summary: reasoning.summary_text, + content: reasoning.raw_content, + }, + CoreTurnItem::WebSearch(search) => ThreadItem::WebSearch { + id: search.id, + query: search.query, + action: Some(WebSearchAction::from(search.action)), + }, + CoreTurnItem::ImageView(image) => ThreadItem::ImageView { + id: image.id, + path: image.path, + }, + CoreTurnItem::ImageGeneration(image) => ThreadItem::ImageGeneration { + id: image.id, + status: image.status, + revised_prompt: image.revised_prompt, + result: image.result, + saved_path: image.saved_path, + }, + CoreTurnItem::FileChange(file_change) => ThreadItem::FileChange { + id: file_change.id, + changes: convert_patch_changes(&file_change.changes), + status: file_change + .status + .as_ref() + .map(PatchApplyStatus::from) + .unwrap_or(PatchApplyStatus::InProgress), + }, + CoreTurnItem::McpToolCall(mcp) => { + let duration_ms = mcp + .duration + .and_then(|duration| i64::try_from(duration.as_millis()).ok()); + + ThreadItem::McpToolCall { + id: mcp.id, + server: mcp.server, + tool: mcp.tool, + status: McpToolCallStatus::from(mcp.status), + arguments: mcp.arguments, + mcp_app_resource_uri: mcp.mcp_app_resource_uri, + result: mcp.result.map(McpToolCallResult::from).map(Box::new), + error: mcp.error.map(McpToolCallError::from), + duration_ms, + } + } + CoreTurnItem::ContextCompaction(compaction) => { + ThreadItem::ContextCompaction { id: compaction.id } + } + } + } +} + +impl From for HookPromptFragment { + fn from(value: codex_protocol::items::HookPromptFragment) -> Self { + Self { + text: value.text, + hook_run_id: value.hook_run_id, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CommandExecutionStatus { + InProgress, + Completed, + Failed, + Declined, +} + +impl From for CommandExecutionStatus { + fn from(value: CoreExecCommandStatus) -> Self { + Self::from(&value) + } +} + +impl From<&CoreExecCommandStatus> for CommandExecutionStatus { + fn from(value: &CoreExecCommandStatus) -> Self { + match value { + CoreExecCommandStatus::Completed => CommandExecutionStatus::Completed, + CoreExecCommandStatus::Failed => CommandExecutionStatus::Failed, + CoreExecCommandStatus::Declined => CommandExecutionStatus::Declined, + } + } +} + +v2_enum_from_core! { + #[derive(Default)] + pub enum CommandExecutionSource from CoreExecCommandSource { + #[default] + Agent, + UserShell, + UnifiedExecStartup, + UnifiedExecInteraction, + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CollabAgentTool { + SpawnAgent, + SendInput, + ResumeAgent, + Wait, + CloseAgent, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileUpdateChange { + pub path: String, + pub kind: PatchChangeKind, + pub diff: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PatchChangeKind { + Add, + Delete, + Update { move_path: Option }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum PatchApplyStatus { + InProgress, + Completed, + Failed, + Declined, +} + +impl From for PatchApplyStatus { + fn from(value: CorePatchApplyStatus) -> Self { + Self::from(&value) + } +} + +impl From<&CorePatchApplyStatus> for PatchApplyStatus { + fn from(value: &CorePatchApplyStatus) -> Self { + match value { + CorePatchApplyStatus::Completed => PatchApplyStatus::Completed, + CorePatchApplyStatus::Failed => PatchApplyStatus::Failed, + CorePatchApplyStatus::Declined => PatchApplyStatus::Declined, + } + } +} + +impl From for McpToolCallStatus { + fn from(value: CoreMcpToolCallStatus) -> Self { + match value { + CoreMcpToolCallStatus::InProgress => McpToolCallStatus::InProgress, + CoreMcpToolCallStatus::Completed => McpToolCallStatus::Completed, + CoreMcpToolCallStatus::Failed => McpToolCallStatus::Failed, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum McpToolCallStatus { + InProgress, + Completed, + Failed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum DynamicToolCallStatus { + InProgress, + Completed, + Failed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CollabAgentToolCallStatus { + InProgress, + Completed, + Failed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CollabAgentStatus { + PendingInit, + Running, + Interrupted, + Completed, + Errored, + Shutdown, + NotFound, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CollabAgentState { + pub status: CollabAgentStatus, + pub message: Option, +} + +impl From for CollabAgentState { + fn from(value: CoreAgentStatus) -> Self { + match value { + CoreAgentStatus::PendingInit => Self { + status: CollabAgentStatus::PendingInit, + message: None, + }, + CoreAgentStatus::Running => Self { + status: CollabAgentStatus::Running, + message: None, + }, + CoreAgentStatus::Interrupted => Self { + status: CollabAgentStatus::Interrupted, + message: None, + }, + CoreAgentStatus::Completed(message) => Self { + status: CollabAgentStatus::Completed, + message, + }, + CoreAgentStatus::Errored(message) => Self { + status: CollabAgentStatus::Errored, + message: Some(message), + }, + CoreAgentStatus::Shutdown => Self { + status: CollabAgentStatus::Shutdown, + message: None, + }, + CoreAgentStatus::NotFound => Self { + status: CollabAgentStatus::NotFound, + message: None, + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ItemStartedNotification { + pub item: ThreadItem, + pub thread_id: String, + pub turn_id: String, + /// Unix timestamp (in milliseconds) when this item lifecycle started. + #[ts(type = "number")] + pub started_at_ms: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Temporary notification payload for approval auto-review. This +/// shape is expected to change soon. +pub struct ItemGuardianApprovalReviewStartedNotification { + pub thread_id: String, + pub turn_id: String, + /// Unix timestamp (in milliseconds) when this review started. + #[ts(type = "number")] + pub started_at_ms: i64, + /// Stable identifier for this review. + pub review_id: String, + /// Identifier for the reviewed item or tool call when one exists. + /// + /// In most cases, one review maps to one target item. The exceptions are + /// - execve reviews, where a single command may contain multiple execve + /// calls to review (only possible when using the shell_zsh_fork feature) + /// - network policy reviews, where there is no target item + /// + /// A network call is triggered by a CommandExecution item, so having a + /// target_item_id set to the CommandExecution item would be misleading + /// because the review is about the network call, not the command execution. + /// Therefore, target_item_id is set to None for network policy reviews. + pub target_item_id: Option, + pub review: GuardianApprovalReview, + pub action: GuardianApprovalReviewAction, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// [UNSTABLE] Temporary notification payload for approval auto-review. This +/// shape is expected to change soon. +pub struct ItemGuardianApprovalReviewCompletedNotification { + pub thread_id: String, + pub turn_id: String, + /// Unix timestamp (in milliseconds) when this review started. + #[ts(type = "number")] + pub started_at_ms: i64, + /// Unix timestamp (in milliseconds) when this review completed. + #[ts(type = "number")] + pub completed_at_ms: i64, + /// Stable identifier for this review. + pub review_id: String, + /// Identifier for the reviewed item or tool call when one exists. + /// + /// In most cases, one review maps to one target item. The exceptions are + /// - execve reviews, where a single command may contain multiple execve + /// calls to review (only possible when using the shell_zsh_fork feature) + /// - network policy reviews, where there is no target item + /// + /// A network call is triggered by a CommandExecution item, so having a + /// target_item_id set to the CommandExecution item would be misleading + /// because the review is about the network call, not the command execution. + /// Therefore, target_item_id is set to None for network policy reviews. + pub target_item_id: Option, + pub decision_source: AutoReviewDecisionSource, + pub review: GuardianApprovalReview, + pub action: GuardianApprovalReviewAction, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ItemCompletedNotification { + pub item: ThreadItem, + pub thread_id: String, + pub turn_id: String, + /// Unix timestamp (in milliseconds) when this item lifecycle completed. + #[ts(type = "number")] + pub completed_at_ms: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RawResponseItemCompletedNotification { + pub thread_id: String, + pub turn_id: String, + pub item: ResponseItem, +} + +// Item-specific progress notifications +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AgentMessageDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should +/// not assume concatenated deltas match the completed plan item content. +pub struct PlanDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReasoningSummaryTextDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, + #[ts(type = "number")] + pub summary_index: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReasoningSummaryPartAddedNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + #[ts(type = "number")] + pub summary_index: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReasoningTextDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, + #[ts(type = "number")] + pub content_index: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TerminalInteractionNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub process_id: String, + pub stdin: String, +} + +#[serde_as] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecutionOutputDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, +} +/// Deprecated legacy notification for `apply_patch` textual output. +/// +/// The server no longer emits this notification. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileChangeOutputDeltaNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub delta: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileChangePatchUpdatedNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub changes: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecutionRequestApprovalParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + /// Unix timestamp (in milliseconds) when this approval request started. + #[ts(type = "number")] + pub started_at_ms: i64, + /// Unique identifier for this specific approval callback. + /// + /// For regular shell/unified_exec approvals, this is null. + /// + /// For zsh-exec-bridge subcommand approvals, multiple callbacks can belong to + /// one parent `itemId`, so `approvalId` is a distinct opaque callback id + /// (a UUID) used to disambiguate routing. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub approval_id: Option, + /// Optional explanatory reason (e.g. request for network access). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub reason: Option, + /// Optional context for a managed-network approval prompt. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub network_approval_context: Option, + /// The command to be executed. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub command: Option, + /// The command's working directory. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub cwd: Option, + /// Best-effort parsed command actions for friendly display. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub command_actions: Option>, + /// Optional additional permissions requested for this command. + #[experimental("item/commandExecution/requestApproval.additionalPermissions")] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub additional_permissions: Option, + /// Optional proposed execpolicy amendment to allow similar commands without prompting. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub proposed_execpolicy_amendment: Option, + /// Optional proposed network policy amendments (allow/deny host) for future requests. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub proposed_network_policy_amendments: Option>, + /// Ordered list of decisions the client may present for this prompt. + #[experimental("item/commandExecution/requestApproval.availableDecisions")] + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub available_decisions: Option>, +} + +impl CommandExecutionRequestApprovalParams { + pub fn strip_experimental_fields(&mut self) { + // TODO: Avoid hardcoding individual experimental fields here. + // We need a generic outbound compatibility design for stripping or + // otherwise handling experimental server->client payloads. + self.additional_permissions = None; + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecutionRequestApprovalResponse { + pub decision: CommandExecutionApprovalDecision, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileChangeRequestApprovalParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + /// Unix timestamp (in milliseconds) when this approval request started. + #[ts(type = "number")] + pub started_at_ms: i64, + /// Optional explanatory reason (e.g. request for extra write access). + #[ts(optional = nullable)] + pub reason: Option, + /// [UNSTABLE] When set, the agent is asking the user to allow writes under this root + /// for the remainder of the session (unclear if this is honored today). + #[ts(optional = nullable)] + pub grant_root: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub struct FileChangeRequestApprovalResponse { + pub decision: FileChangeApprovalDecision, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DynamicToolCallParams { + pub thread_id: String, + pub turn_id: String, + pub call_id: String, + pub namespace: Option, + pub tool: String, + pub arguments: JsonValue, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DynamicToolCallResponse { + pub content_items: Vec, + pub success: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum DynamicToolCallOutputContentItem { + #[serde(rename_all = "camelCase")] + InputText { text: String }, + #[serde(rename_all = "camelCase")] + InputImage { image_url: String }, +} + +impl From + for codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem +{ + fn from(item: DynamicToolCallOutputContentItem) -> Self { + match item { + DynamicToolCallOutputContentItem::InputText { text } => Self::InputText { text }, + DynamicToolCallOutputContentItem::InputImage { image_url } => { + Self::InputImage { image_url } + } + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Defines a single selectable option for request_user_input. +pub struct ToolRequestUserInputOption { + pub label: String, + pub description: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Represents one request_user_input question and its required options. +pub struct ToolRequestUserInputQuestion { + pub id: String, + pub header: String, + pub question: String, + #[serde(default)] + pub is_other: bool, + #[serde(default)] + pub is_secret: bool, + pub options: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Params sent with a request_user_input event. +pub struct ToolRequestUserInputParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub questions: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Captures a user's answer to a request_user_input question. +pub struct ToolRequestUserInputAnswer { + pub answers: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// EXPERIMENTAL. Response payload mapping question ids to answers. +pub struct ToolRequestUserInputResponse { + pub answers: HashMap, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs new file mode 100644 index 0000000000..9fd9384076 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/mcp.rs @@ -0,0 +1,703 @@ +use super::shared::v2_enum_from_core; +use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; +use codex_protocol::items::McpToolCallError as CoreMcpToolCallError; +use codex_protocol::mcp::CallToolResult as CoreMcpCallToolResult; +use codex_protocol::mcp::Resource as McpResource; +pub use codex_protocol::mcp::ResourceContent as McpResourceContent; +use codex_protocol::mcp::ResourceTemplate as McpResourceTemplate; +use codex_protocol::mcp::Tool as McpTool; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use std::collections::BTreeMap; +use ts_rs::TS; + +v2_enum_from_core!( + pub enum McpAuthStatus from codex_protocol::protocol::McpAuthStatus { + Unsupported, + NotLoggedIn, + BearerToken, + OAuth + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ListMcpServerStatusParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a server-defined value. + #[ts(optional = nullable)] + pub limit: Option, + /// Controls how much MCP inventory data to fetch for each server. + /// Defaults to `Full` when omitted. + #[ts(optional = nullable)] + pub detail: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum McpServerStatusDetail { + Full, + ToolsAndAuthOnly, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerStatus { + pub name: String, + pub tools: std::collections::HashMap, + pub resources: Vec, + pub resource_templates: Vec, + pub auth_status: McpAuthStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ListMcpServerStatusResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// If None, there are no more items to return. + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpResourceReadParams { + #[ts(optional = nullable)] + pub thread_id: Option, + pub server: String, + pub uri: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpResourceReadResponse { + pub contents: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerToolCallParams { + pub thread_id: String, + pub server: String, + pub tool: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub arguments: Option, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub meta: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerToolCallResponse { + pub content: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub structured_content: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub is_error: Option, + #[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub meta: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpToolCallResult { + // NOTE: `rmcp::model::Content` (and its `RawContent` variants) would be a more precise Rust + // representation of MCP content blocks. We intentionally use `serde_json::Value` here because + // this crate exports JSON schema + TS types (`schemars`/`ts-rs`), and the rmcp model types + // aren't set up to be schema/TS friendly (and would introduce heavier coupling to rmcp's Rust + // representations). Using `JsonValue` keeps the payload wire-shaped and easy to export. + pub content: Vec, + pub structured_content: Option, + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + pub meta: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpToolCallError { + pub message: String, +} + +impl From for McpServerToolCallResponse { + fn from(result: CoreMcpCallToolResult) -> Self { + Self { + content: result.content, + structured_content: result.structured_content, + is_error: result.is_error, + meta: result.meta, + } + } +} + +impl From for McpToolCallResult { + fn from(result: CoreMcpCallToolResult) -> Self { + Self { + content: result.content, + structured_content: result.structured_content, + meta: result.meta, + } + } +} + +impl From for McpToolCallError { + fn from(error: CoreMcpToolCallError) -> Self { + Self { + message: error.message, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerRefreshParams {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerRefreshResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginParams { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub scopes: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional = nullable)] + pub timeout_secs: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginResponse { + pub authorization_url: String, +} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpToolCallProgressNotification { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerOauthLoginCompletedNotification { + pub name: String, + pub success: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub error: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum McpServerStartupState { + Starting, + Ready, + Failed, + Cancelled, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerStatusUpdatedNotification { + pub name: String, + pub status: McpServerStartupState, + pub error: 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 McpServerElicitationAction { + Accept, + Decline, + Cancel, +} + +impl McpServerElicitationAction { + pub fn to_core(self) -> codex_protocol::approvals::ElicitationAction { + match self { + Self::Accept => codex_protocol::approvals::ElicitationAction::Accept, + Self::Decline => codex_protocol::approvals::ElicitationAction::Decline, + Self::Cancel => codex_protocol::approvals::ElicitationAction::Cancel, + } + } +} + +impl From for rmcp::model::ElicitationAction { + fn from(value: McpServerElicitationAction) -> Self { + match value { + McpServerElicitationAction::Accept => Self::Accept, + McpServerElicitationAction::Decline => Self::Decline, + McpServerElicitationAction::Cancel => Self::Cancel, + } + } +} + +impl From for McpServerElicitationAction { + fn from(value: rmcp::model::ElicitationAction) -> Self { + match value { + rmcp::model::ElicitationAction::Accept => Self::Accept, + rmcp::model::ElicitationAction::Decline => Self::Decline, + rmcp::model::ElicitationAction::Cancel => Self::Cancel, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerElicitationRequestParams { + pub thread_id: String, + /// Active Codex turn when this elicitation was observed, if app-server could correlate one. + /// + /// This is nullable because MCP models elicitation as a standalone server-to-client request + /// identified by the MCP server request id. It may be triggered during a turn, but turn + /// context is app-server correlation rather than part of the protocol identity of the + /// elicitation itself. + pub turn_id: Option, + pub server_name: String, + #[serde(flatten)] + pub request: McpServerElicitationRequest, + // TODO: When core can correlate an elicitation with an MCP tool call, expose the associated + // McpToolCall item id here as an optional field. The current core event does not carry that + // association. +} + +/// Typed form schema for MCP `elicitation/create` requests. +/// +/// This matches the `requestedSchema` shape from the MCP 2025-11-25 +/// `ElicitRequestFormParams` schema. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationSchema { + #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")] + #[ts(optional, rename = "$schema")] + pub schema_uri: Option, + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationObjectType, + pub properties: BTreeMap, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub required: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationObjectType { + Object, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(untagged)] +#[ts(export_to = "v2/")] +pub enum McpElicitationPrimitiveSchema { + Enum(McpElicitationEnumSchema), + String(McpElicitationStringSchema), + Number(McpElicitationNumberSchema), + Boolean(McpElicitationBooleanSchema), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationStringSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub min_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub max_length: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub format: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationStringType { + String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum McpElicitationStringFormat { + Email, + Uri, + Date, + DateTime, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationNumberSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationNumberType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub minimum: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub maximum: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationNumberType { + Number, + Integer, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationBooleanSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationBooleanType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationBooleanType { + Boolean, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(untagged)] +#[ts(export_to = "v2/")] +pub enum McpElicitationEnumSchema { + SingleSelect(McpElicitationSingleSelectEnumSchema), + MultiSelect(McpElicitationMultiSelectEnumSchema), + Legacy(McpElicitationLegacyTitledEnumSchema), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationLegacyTitledEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(rename = "enum")] + #[ts(rename = "enum")] + pub enum_: Vec, + #[serde(rename = "enumNames", skip_serializing_if = "Option::is_none")] + #[ts(optional, rename = "enumNames")] + pub enum_names: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(untagged)] +#[ts(export_to = "v2/")] +pub enum McpElicitationSingleSelectEnumSchema { + Untitled(McpElicitationUntitledSingleSelectEnumSchema), + Titled(McpElicitationTitledSingleSelectEnumSchema), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationUntitledSingleSelectEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(rename = "enum")] + #[ts(rename = "enum")] + pub enum_: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationTitledSingleSelectEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(rename = "oneOf")] + #[ts(rename = "oneOf")] + pub one_of: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(untagged)] +#[ts(export_to = "v2/")] +pub enum McpElicitationMultiSelectEnumSchema { + Untitled(McpElicitationUntitledMultiSelectEnumSchema), + Titled(McpElicitationTitledMultiSelectEnumSchema), +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationUntitledMultiSelectEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationArrayType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub min_items: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub max_items: Option, + pub items: McpElicitationUntitledEnumItems, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationTitledMultiSelectEnumSchema { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationArrayType, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub min_items: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub max_items: Option, + pub items: McpElicitationTitledEnumItems, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub default: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum McpElicitationArrayType { + Array, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationUntitledEnumItems { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub type_: McpElicitationStringType, + #[serde(rename = "enum")] + #[ts(rename = "enum")] + pub enum_: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationTitledEnumItems { + #[serde(rename = "anyOf", alias = "oneOf")] + #[ts(rename = "anyOf")] + pub any_of: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct McpElicitationConstOption { + #[serde(rename = "const")] + #[ts(rename = "const")] + pub const_: String, + pub title: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "mode", rename_all = "camelCase")] +#[ts(tag = "mode")] +#[ts(export_to = "v2/")] +pub enum McpServerElicitationRequest { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Form { + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + meta: Option, + message: String, + requested_schema: McpElicitationSchema, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Url { + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + meta: Option, + message: String, + url: String, + elicitation_id: String, + }, +} + +impl TryFrom for McpServerElicitationRequest { + type Error = serde_json::Error; + + fn try_from(value: CoreElicitationRequest) -> Result { + match value { + CoreElicitationRequest::Form { + meta, + message, + requested_schema, + } => Ok(Self::Form { + meta, + message, + requested_schema: serde_json::from_value(requested_schema)?, + }), + CoreElicitationRequest::Url { + meta, + message, + url, + elicitation_id, + } => Ok(Self::Url { + meta, + message, + url, + elicitation_id, + }), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpServerElicitationRequestResponse { + pub action: McpServerElicitationAction, + /// Structured user input for accepted elicitations, mirroring RMCP `CreateElicitationResult`. + /// + /// This is nullable because decline/cancel responses have no content. + pub content: Option, + /// Optional client metadata for form-mode action handling. + #[serde(rename = "_meta")] + #[ts(rename = "_meta")] + pub meta: Option, +} + +impl From for rmcp::model::CreateElicitationResult { + fn from(value: McpServerElicitationRequestResponse) -> Self { + Self { + action: value.action.into(), + content: value.content, + } + } +} + +impl From for McpServerElicitationRequestResponse { + fn from(value: rmcp::model::CreateElicitationResult) -> Self { + Self { + action: value.action.into(), + content: value.content, + meta: None, + } + } +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs new file mode 100644 index 0000000000..b5fa9fdc65 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -0,0 +1,57 @@ +mod shared; + +mod account; +mod apps; +mod attestation; +mod collaboration_mode; +mod command_exec; +mod config; +mod environment; +mod experimental_feature; +mod feedback; +mod fs; +mod hook; +mod item; +mod mcp; +mod model; +mod notification; +mod permissions; +mod plugin; +mod process; +mod realtime; +mod remote_control; +mod review; +mod thread; +mod thread_data; +mod turn; +mod windows_sandbox; + +pub use account::*; +pub use apps::*; +pub use attestation::*; +pub use collaboration_mode::*; +pub use command_exec::*; +pub use config::*; +pub use environment::*; +pub use experimental_feature::*; +pub use feedback::*; +pub use fs::*; +pub use hook::*; +pub use item::*; +pub use mcp::*; +pub use model::*; +pub use notification::*; +pub use permissions::*; +pub use plugin::*; +pub use process::*; +pub use realtime::*; +pub use remote_control::*; +pub use review::*; +pub use shared::*; +pub use thread::*; +pub use thread_data::*; +pub use turn::*; +pub use windows_sandbox::*; + +#[cfg(test)] +mod tests; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/model.rs b/codex-rs/app-server-protocol/src/protocol/v2/model.rs new file mode 100644 index 0000000000..cd139e9c4b --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/model.rs @@ -0,0 +1,151 @@ +use super::shared::v2_enum_from_core; +use codex_protocol::openai_models::InputModality; +use codex_protocol::openai_models::ModelAvailabilityNux as CoreModelAvailabilityNux; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::openai_models::default_input_modalities; +use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason; +use codex_protocol::protocol::ModelVerification as CoreModelVerification; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +v2_enum_from_core!( + pub enum ModelRerouteReason from CoreModelRerouteReason { + HighRiskCyberActivity + } +); + +v2_enum_from_core!( + pub enum ModelVerification from CoreModelVerification { + TrustedAccessForCyber + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelProviderCapabilitiesReadParams {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelProviderCapabilitiesReadResponse { + pub namespace_tools: bool, + pub image_generation: bool, + pub web_search: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + #[ts(optional = nullable)] + pub limit: Option, + /// When true, include models that are hidden from the default picker list. + #[ts(optional = nullable)] + pub include_hidden: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelAvailabilityNux { + pub message: String, +} + +impl From for ModelAvailabilityNux { + fn from(value: CoreModelAvailabilityNux) -> Self { + Self { + message: value.message, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelServiceTier { + pub id: String, + pub name: String, + pub description: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Model { + pub id: String, + pub model: String, + pub upgrade: Option, + pub upgrade_info: Option, + pub availability_nux: Option, + pub display_name: String, + pub description: String, + pub hidden: bool, + pub supported_reasoning_efforts: Vec, + pub default_reasoning_effort: ReasoningEffort, + #[serde(default = "default_input_modalities")] + pub input_modalities: Vec, + #[serde(default)] + pub supports_personality: bool, + /// Deprecated: use `serviceTiers` instead. + #[serde(default)] + pub additional_speed_tiers: Vec, + #[serde(default)] + pub service_tiers: Vec, + // Only one model should be marked as default. + pub is_default: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelUpgradeInfo { + pub model: String, + pub upgrade_copy: Option, + pub model_link: Option, + pub migration_markdown: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReasoningEffortOption { + pub reasoning_effort: ReasoningEffort, + pub description: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// If None, there are no more items to return. + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelReroutedNotification { + pub thread_id: String, + pub turn_id: String, + pub from_model: String, + pub to_model: String, + pub reason: ModelRerouteReason, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelVerificationNotification { + pub thread_id: String, + pub turn_id: String, + pub verifications: Vec, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/notification.rs b/codex-rs/app-server-protocol/src/protocol/v2/notification.rs new file mode 100644 index 0000000000..8289cf5683 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/notification.rs @@ -0,0 +1,56 @@ +use super::TurnError; +use crate::RequestId; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DeprecationNoticeNotification { + /// Concise summary of what is deprecated. + pub summary: String, + /// Optional extra guidance, such as migration steps or rationale. + pub details: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WarningNotification { + /// Optional thread target when the warning applies to a specific thread. + pub thread_id: Option, + /// Concise warning message for the user. + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GuardianWarningNotification { + /// Thread target for the guardian warning. + pub thread_id: String, + /// Concise guardian warning message for the user. + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ErrorNotification { + pub error: TurnError, + // Set to true if the error is transient and the app-server process will automatically retry. + // If true, this will not interrupt a turn. + pub will_retry: bool, + pub thread_id: String, + pub turn_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ServerRequestResolvedNotification { + pub thread_id: String, + pub request_id: RequestId, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs new file mode 100644 index 0000000000..86614a6aeb --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -0,0 +1,857 @@ +use super::shared::v2_enum_from_core; +use codex_protocol::approvals::ExecPolicyAmendment as CoreExecPolicyAmendment; +use codex_protocol::approvals::NetworkApprovalContext as CoreNetworkApprovalContext; +use codex_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalProtocol; +use codex_protocol::approvals::NetworkPolicyAmendment as CoreNetworkPolicyAmendment; +use codex_protocol::approvals::NetworkPolicyRuleAction as CoreNetworkPolicyRuleAction; +use codex_protocol::models::ActivePermissionProfile as CoreActivePermissionProfile; +use codex_protocol::models::ActivePermissionProfileModification as CoreActivePermissionProfileModification; +use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; +use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; +use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; +use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; +use codex_protocol::models::PermissionProfile as CorePermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialPath; +use codex_protocol::permissions::NetworkSandboxPolicy as CoreNetworkSandboxPolicy; +use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; +use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; +use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::num::NonZeroUsize; +use std::path::PathBuf; +use ts_rs::TS; + +v2_enum_from_core! { + pub enum NetworkApprovalProtocol from CoreNetworkApprovalProtocol { + Http, + Https, + Socks5Tcp, + Socks5Udp, + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct NetworkApprovalContext { + pub host: String, + pub protocol: NetworkApprovalProtocol, +} + +impl From for NetworkApprovalContext { + fn from(value: CoreNetworkApprovalContext) -> Self { + Self { + host: value.host, + protocol: value.protocol.into(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AdditionalFileSystemPermissions { + /// This will be removed in favor of `entries`. + pub read: Option>, + /// This will be removed in favor of `entries`. + pub write: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub glob_scan_max_depth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub entries: Option>, +} + +impl From for AdditionalFileSystemPermissions { + fn from(value: CoreFileSystemPermissions) -> Self { + if let Some((read, write)) = value.legacy_read_write_roots() { + let mut entries = Vec::with_capacity( + read.as_ref().map_or(0, Vec::len) + write.as_ref().map_or(0, Vec::len), + ); + if let Some(paths) = read.as_ref() { + entries.extend(paths.iter().map(|path| FileSystemSandboxEntry { + path: FileSystemPath::Path { path: path.clone() }, + access: FileSystemAccessMode::Read, + })); + } + if let Some(paths) = write.as_ref() { + entries.extend(paths.iter().map(|path| FileSystemSandboxEntry { + path: FileSystemPath::Path { path: path.clone() }, + access: FileSystemAccessMode::Write, + })); + } + Self { + read, + write, + glob_scan_max_depth: None, + entries: Some(entries), + } + } else { + Self { + read: None, + write: None, + glob_scan_max_depth: value.glob_scan_max_depth, + entries: Some( + value + .entries + .into_iter() + .map(FileSystemSandboxEntry::from) + .collect(), + ), + } + } + } +} + +impl From for CoreFileSystemPermissions { + fn from(value: AdditionalFileSystemPermissions) -> Self { + let mut permissions = if let Some(entries) = value.entries { + Self { + entries: entries + .into_iter() + .map(CoreFileSystemSandboxEntry::from) + .collect(), + glob_scan_max_depth: None, + } + } else { + CoreFileSystemPermissions::from_read_write_roots(value.read, value.write) + }; + permissions.glob_scan_max_depth = value.glob_scan_max_depth; + permissions + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AdditionalNetworkPermissions { + pub enabled: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionProfileNetworkPermissions { + pub enabled: bool, +} + +impl From for AdditionalNetworkPermissions { + fn from(value: CoreNetworkPermissions) -> Self { + Self { + enabled: value.enabled, + } + } +} + +impl From for CoreNetworkPermissions { + fn from(value: AdditionalNetworkPermissions) -> Self { + Self { + enabled: value.enabled, + } + } +} + +impl From for PermissionProfileNetworkPermissions { + fn from(value: CoreNetworkSandboxPolicy) -> Self { + Self { + enabled: value.is_enabled(), + } + } +} + +impl From for CoreNetworkSandboxPolicy { + fn from(value: PermissionProfileNetworkPermissions) -> Self { + if value.enabled { + Self::Enabled + } else { + Self::Restricted + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[serde(deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct RequestPermissionProfile { + pub network: Option, + pub file_system: Option, +} + +impl From for RequestPermissionProfile { + fn from(value: CoreRequestPermissionProfile) -> Self { + Self { + network: value.network.map(AdditionalNetworkPermissions::from), + file_system: value.file_system.map(AdditionalFileSystemPermissions::from), + } + } +} + +impl From for CoreRequestPermissionProfile { + fn from(value: RequestPermissionProfile) -> Self { + Self { + network: value.network.map(CoreNetworkPermissions::from), + file_system: value.file_system.map(CoreFileSystemPermissions::from), + } + } +} + +v2_enum_from_core!( + pub enum FileSystemAccessMode from CoreFileSystemAccessMode { + Read, + Write, + None + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "kind", rename_all = "snake_case")] +#[ts(tag = "kind")] +#[ts(export_to = "v2/")] +pub enum FileSystemSpecialPath { + Root, + Minimal, + #[serde(alias = "current_working_directory")] + ProjectRoots { + subpath: Option, + }, + Tmpdir, + SlashTmp, + Unknown { + path: String, + subpath: Option, + }, +} + +impl From for FileSystemSpecialPath { + fn from(value: CoreFileSystemSpecialPath) -> Self { + match value { + CoreFileSystemSpecialPath::Root => Self::Root, + CoreFileSystemSpecialPath::Minimal => Self::Minimal, + CoreFileSystemSpecialPath::ProjectRoots { subpath } => Self::ProjectRoots { subpath }, + CoreFileSystemSpecialPath::Tmpdir => Self::Tmpdir, + CoreFileSystemSpecialPath::SlashTmp => Self::SlashTmp, + CoreFileSystemSpecialPath::Unknown { path, subpath } => Self::Unknown { path, subpath }, + } + } +} + +impl From for CoreFileSystemSpecialPath { + fn from(value: FileSystemSpecialPath) -> Self { + match value { + FileSystemSpecialPath::Root => Self::Root, + FileSystemSpecialPath::Minimal => Self::Minimal, + FileSystemSpecialPath::ProjectRoots { subpath } => Self::ProjectRoots { subpath }, + FileSystemSpecialPath::Tmpdir => Self::Tmpdir, + FileSystemSpecialPath::SlashTmp => Self::SlashTmp, + FileSystemSpecialPath::Unknown { path, subpath } => Self::Unknown { path, subpath }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum FileSystemPath { + Path { path: AbsolutePathBuf }, + GlobPattern { pattern: String }, + Special { value: FileSystemSpecialPath }, +} + +impl From for FileSystemPath { + fn from(value: CoreFileSystemPath) -> Self { + match value { + CoreFileSystemPath::Path { path } => Self::Path { path }, + CoreFileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern }, + CoreFileSystemPath::Special { value } => Self::Special { + value: value.into(), + }, + } + } +} + +impl From for CoreFileSystemPath { + fn from(value: FileSystemPath) -> Self { + match value { + FileSystemPath::Path { path } => Self::Path { path }, + FileSystemPath::GlobPattern { pattern } => Self::GlobPattern { pattern }, + FileSystemPath::Special { value } => Self::Special { + value: value.into(), + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileSystemSandboxEntry { + pub path: FileSystemPath, + pub access: FileSystemAccessMode, +} + +impl From for FileSystemSandboxEntry { + fn from(value: CoreFileSystemSandboxEntry) -> Self { + Self { + path: value.path.into(), + access: value.access.into(), + } + } +} + +impl From for CoreFileSystemSandboxEntry { + fn from(value: FileSystemSandboxEntry) -> Self { + Self { + path: value.path.into(), + access: value.access.to_core(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfileFileSystemPermissions { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Restricted { + entries: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + glob_scan_max_depth: Option, + }, + Unrestricted, +} + +impl From for PermissionProfileFileSystemPermissions { + fn from(value: CoreManagedFileSystemPermissions) -> Self { + match value { + CoreManagedFileSystemPermissions::Restricted { + entries, + glob_scan_max_depth, + } => Self::Restricted { + entries: entries + .into_iter() + .map(FileSystemSandboxEntry::from) + .collect(), + glob_scan_max_depth, + }, + CoreManagedFileSystemPermissions::Unrestricted => Self::Unrestricted, + } + } +} + +impl From for CoreManagedFileSystemPermissions { + fn from(value: PermissionProfileFileSystemPermissions) -> Self { + match value { + PermissionProfileFileSystemPermissions::Restricted { + entries, + glob_scan_max_depth, + } => Self::Restricted { + entries: entries + .into_iter() + .map(CoreFileSystemSandboxEntry::from) + .collect(), + glob_scan_max_depth, + }, + PermissionProfileFileSystemPermissions::Unrestricted => Self::Unrestricted, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfile { + /// Codex owns sandbox construction for this profile. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Managed { + network: PermissionProfileNetworkPermissions, + file_system: PermissionProfileFileSystemPermissions, + }, + /// Do not apply an outer sandbox. + Disabled, + /// Filesystem isolation is enforced by an external caller. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + External { + network: PermissionProfileNetworkPermissions, + }, +} + +impl From for PermissionProfile { + fn from(value: CorePermissionProfile) -> Self { + match value { + CorePermissionProfile::Managed { + file_system, + network, + } => Self::Managed { + network: network.into(), + file_system: file_system.into(), + }, + CorePermissionProfile::Disabled => Self::Disabled, + CorePermissionProfile::External { network } => Self::External { + network: network.into(), + }, + } + } +} + +impl From for CorePermissionProfile { + fn from(value: PermissionProfile) -> Self { + match value { + PermissionProfile::Managed { + file_system, + network, + } => Self::Managed { + file_system: file_system.into(), + network: network.into(), + }, + PermissionProfile::Disabled => Self::Disabled, + PermissionProfile::External { network } => Self::External { + network: network.into(), + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ActivePermissionProfile { + /// Identifier from `default_permissions` or the implicit built-in default, + /// such as `:workspace` or a user-defined `[permissions.]` profile. + pub id: String, + /// Parent profile identifier once permissions profiles support + /// inheritance. This is currently always `null`. + #[serde(default)] + pub extends: Option, + /// Bounded user-requested modifications applied on top of the named + /// profile, if any. + #[serde(default)] + pub modifications: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ActivePermissionProfileModification { + /// Additional concrete directory that should be writable. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + AdditionalWritableRoot { path: AbsolutePathBuf }, +} + +impl From for ActivePermissionProfileModification { + fn from(value: CoreActivePermissionProfileModification) -> Self { + match value { + CoreActivePermissionProfileModification::AdditionalWritableRoot { path } => { + Self::AdditionalWritableRoot { path } + } + } + } +} + +impl From for CoreActivePermissionProfileModification { + fn from(value: ActivePermissionProfileModification) -> Self { + match value { + ActivePermissionProfileModification::AdditionalWritableRoot { path } => { + Self::AdditionalWritableRoot { path } + } + } + } +} + +impl From for ActivePermissionProfile { + fn from(value: CoreActivePermissionProfile) -> Self { + Self { + id: value.id, + extends: value.extends, + modifications: value + .modifications + .into_iter() + .map(ActivePermissionProfileModification::from) + .collect(), + } + } +} + +impl From for CoreActivePermissionProfile { + fn from(value: ActivePermissionProfile) -> Self { + Self { + id: value.id, + extends: value.extends, + modifications: value + .modifications + .into_iter() + .map(CoreActivePermissionProfileModification::from) + .collect(), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfileSelectionParams { + /// Select a named built-in or user-defined profile and optionally apply + /// bounded modifications that Codex knows how to validate. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Profile { + id: String, + #[ts(optional = nullable)] + modifications: Option>, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PermissionProfileModificationParams { + /// Additional concrete directory that should be writable. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + AdditionalWritableRoot { path: AbsolutePathBuf }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AdditionalPermissionProfile { + /// Partial overlay used for per-command permission requests. + pub network: Option, + pub file_system: Option, +} + +impl From for AdditionalPermissionProfile { + fn from(value: CoreAdditionalPermissionProfile) -> Self { + Self { + network: value.network.map(AdditionalNetworkPermissions::from), + file_system: value.file_system.map(AdditionalFileSystemPermissions::from), + } + } +} + +impl From for CoreAdditionalPermissionProfile { + fn from(value: AdditionalPermissionProfile) -> Self { + Self { + network: value.network.map(CoreNetworkPermissions::from), + file_system: value.file_system.map(CoreFileSystemPermissions::from), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GrantedPermissionProfile { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub network: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub file_system: Option, +} + +impl From for CoreAdditionalPermissionProfile { + fn from(value: GrantedPermissionProfile) -> Self { + Self { + network: value.network.map(CoreNetworkPermissions::from), + file_system: value.file_system.map(CoreFileSystemPermissions::from), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum NetworkAccess { + #[default] + Restricted, + Enabled, +} + +#[derive(Serialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum SandboxPolicy { + DangerFullAccess, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ReadOnly { + #[serde(default)] + network_access: bool, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + ExternalSandbox { + #[serde(default)] + network_access: NetworkAccess, + }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + WorkspaceWrite { + #[serde(default)] + writable_roots: Vec, + #[serde(default)] + network_access: bool, + #[serde(default)] + exclude_tmpdir_env_var: bool, + #[serde(default)] + exclude_slash_tmp: bool, + }, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +enum SandboxPolicyDeserialize { + DangerFullAccess, + #[serde(rename_all = "camelCase")] + ReadOnly { + #[serde(default)] + network_access: bool, + #[serde(default)] + access: Option, + }, + #[serde(rename_all = "camelCase")] + ExternalSandbox { + #[serde(default)] + network_access: NetworkAccess, + }, + #[serde(rename_all = "camelCase")] + WorkspaceWrite { + #[serde(default)] + writable_roots: Vec, + #[serde(default)] + read_only_access: Option, + #[serde(default)] + network_access: bool, + #[serde(default)] + exclude_tmpdir_env_var: bool, + #[serde(default)] + exclude_slash_tmp: bool, + }, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +enum LegacyReadOnlyAccess { + FullAccess, + Restricted, +} + +impl<'de> Deserialize<'de> for SandboxPolicy { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + match SandboxPolicyDeserialize::deserialize(deserializer)? { + SandboxPolicyDeserialize::DangerFullAccess => Ok(SandboxPolicy::DangerFullAccess), + SandboxPolicyDeserialize::ReadOnly { + network_access, + access, + } => { + if matches!(access, Some(LegacyReadOnlyAccess::Restricted)) { + return Err(serde::de::Error::custom( + "readOnly.access is no longer supported; use permissionProfile for restricted reads", + )); + } + Ok(SandboxPolicy::ReadOnly { network_access }) + } + SandboxPolicyDeserialize::ExternalSandbox { network_access } => { + Ok(SandboxPolicy::ExternalSandbox { network_access }) + } + SandboxPolicyDeserialize::WorkspaceWrite { + writable_roots, + read_only_access, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + } => { + if matches!(read_only_access, Some(LegacyReadOnlyAccess::Restricted)) { + return Err(serde::de::Error::custom( + "workspaceWrite.readOnlyAccess is no longer supported; use permissionProfile for restricted reads", + )); + } + Ok(SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + }) + } + } + } +} + +impl SandboxPolicy { + pub fn to_core(&self) -> codex_protocol::protocol::SandboxPolicy { + match self { + SandboxPolicy::DangerFullAccess => { + codex_protocol::protocol::SandboxPolicy::DangerFullAccess + } + SandboxPolicy::ReadOnly { network_access } => { + codex_protocol::protocol::SandboxPolicy::ReadOnly { + network_access: *network_access, + } + } + SandboxPolicy::ExternalSandbox { network_access } => { + codex_protocol::protocol::SandboxPolicy::ExternalSandbox { + network_access: match network_access { + NetworkAccess::Restricted => CoreNetworkAccess::Restricted, + NetworkAccess::Enabled => CoreNetworkAccess::Enabled, + }, + } + } + SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + } => codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { + writable_roots: writable_roots.clone(), + network_access: *network_access, + exclude_tmpdir_env_var: *exclude_tmpdir_env_var, + exclude_slash_tmp: *exclude_slash_tmp, + }, + } + } +} + +impl From for SandboxPolicy { + fn from(value: codex_protocol::protocol::SandboxPolicy) -> Self { + match value { + codex_protocol::protocol::SandboxPolicy::DangerFullAccess => { + SandboxPolicy::DangerFullAccess + } + codex_protocol::protocol::SandboxPolicy::ReadOnly { network_access } => { + SandboxPolicy::ReadOnly { network_access } + } + codex_protocol::protocol::SandboxPolicy::ExternalSandbox { network_access } => { + SandboxPolicy::ExternalSandbox { + network_access: match network_access { + CoreNetworkAccess::Restricted => NetworkAccess::Restricted, + CoreNetworkAccess::Enabled => NetworkAccess::Enabled, + }, + } + } + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + } => SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(transparent)] +#[ts(type = "Array", export_to = "v2/")] +pub struct ExecPolicyAmendment { + pub command: Vec, +} + +impl ExecPolicyAmendment { + pub fn into_core(self) -> CoreExecPolicyAmendment { + CoreExecPolicyAmendment::new(self.command) + } +} + +impl From for ExecPolicyAmendment { + fn from(value: CoreExecPolicyAmendment) -> Self { + Self { + command: value.command().to_vec(), + } + } +} + +v2_enum_from_core!( + pub enum NetworkPolicyRuleAction from CoreNetworkPolicyRuleAction { + Allow, Deny + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct NetworkPolicyAmendment { + pub host: String, + pub action: NetworkPolicyRuleAction, +} + +impl NetworkPolicyAmendment { + pub fn into_core(self) -> CoreNetworkPolicyAmendment { + CoreNetworkPolicyAmendment { + host: self.host, + action: self.action.to_core(), + } + } +} + +impl From for NetworkPolicyAmendment { + fn from(value: CoreNetworkPolicyAmendment) -> Self { + Self { + host: value.host, + action: NetworkPolicyRuleAction::from(value.action), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionsRequestApprovalParams { + pub thread_id: String, + pub turn_id: String, + pub item_id: String, + /// Unix timestamp (in milliseconds) when this approval request started. + #[ts(type = "number")] + pub started_at_ms: i64, + pub cwd: AbsolutePathBuf, + pub reason: Option, + pub permissions: RequestPermissionProfile, +} + +v2_enum_from_core!( + #[derive(Default)] + pub enum PermissionGrantScope from CorePermissionGrantScope { + #[default] + Turn, + Session + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PermissionsRequestApprovalResponse { + pub permissions: GrantedPermissionProfile, + #[serde(default)] + pub scope: PermissionGrantScope, + /// Review every subsequent command in this turn before normal sandboxed execution. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub strict_auto_review: Option, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs new file mode 100644 index 0000000000..2349e8bc21 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -0,0 +1,784 @@ +use super::AppSummary; +use super::HookEventName; +use super::HookHandlerType; +use super::HookSource; +use super::HookTrustStatus; +use codex_protocol::protocol::SkillDependencies as CoreSkillDependencies; +use codex_protocol::protocol::SkillInterface as CoreSkillInterface; +use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; +use codex_protocol::protocol::SkillScope as CoreSkillScope; +use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsListParams { + /// When empty, defaults to the current session working directory. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cwds: Vec, + + /// When true, bypass the skills cache and re-scan skills from disk. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub force_reload: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsListResponse { + pub data: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksListParams { + /// When empty, defaults to the current session working directory. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub cwds: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksListResponse { + pub data: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceAddParams { + pub source: String, + #[ts(optional = nullable)] + pub ref_name: Option, + #[ts(optional = nullable)] + pub sparse_paths: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceAddResponse { + pub marketplace_name: String, + pub installed_root: AbsolutePathBuf, + pub already_added: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceRemoveParams { + pub marketplace_name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceRemoveResponse { + pub marketplace_name: String, + pub installed_root: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceUpgradeParams { + #[ts(optional = nullable)] + pub marketplace_name: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceUpgradeResponse { + pub selected_marketplaces: Vec, + pub upgraded_roots: Vec, + pub errors: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceUpgradeErrorInfo { + pub marketplace_name: String, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginListParams { + /// Optional working directories used to discover repo marketplaces. When omitted, + /// only home-scoped marketplaces and the official curated marketplace are considered. + #[ts(optional = nullable)] + pub cwds: Option>, + /// Optional marketplace kind filter. When omitted, only local marketplaces are queried, plus + /// the default remote catalog when enabled by feature flag. + #[ts(optional = nullable)] + pub marketplace_kinds: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginListMarketplaceKind { + #[serde(rename = "local")] + #[ts(rename = "local")] + Local, + #[serde(rename = "workspace-directory")] + #[ts(rename = "workspace-directory")] + WorkspaceDirectory, + #[serde(rename = "shared-with-me")] + #[ts(rename = "shared-with-me")] + SharedWithMe, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginListResponse { + pub marketplaces: Vec, + #[serde(default)] + pub marketplace_load_errors: Vec, + #[serde(default)] + pub featured_plugin_ids: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceLoadErrorInfo { + pub marketplace_path: AbsolutePathBuf, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginReadParams { + #[ts(optional = nullable)] + pub marketplace_path: Option, + #[ts(optional = nullable)] + pub remote_marketplace_name: Option, + pub plugin_name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginReadResponse { + pub plugin: PluginDetail, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginSkillReadParams { + pub remote_marketplace_name: String, + pub remote_plugin_id: String, + pub skill_name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginSkillReadResponse { + pub contents: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareSaveParams { + pub plugin_path: AbsolutePathBuf, + #[ts(optional = nullable)] + pub remote_plugin_id: Option, + #[ts(optional = nullable)] + pub discoverability: Option, + #[ts(optional = nullable)] + pub share_targets: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareSaveResponse { + pub remote_plugin_id: String, + pub share_url: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareUpdateTargetsParams { + pub remote_plugin_id: String, + pub discoverability: PluginShareUpdateDiscoverability, + pub share_targets: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareUpdateTargetsResponse { + pub principals: Vec, + pub discoverability: PluginShareDiscoverability, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareListParams {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareListResponse { + pub data: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareDeleteParams { + pub remote_plugin_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareDeleteResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareListItem { + pub plugin: PluginSummary, + pub local_plugin_path: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginShareDiscoverability { + #[serde(rename = "LISTED")] + #[ts(rename = "LISTED")] + Listed, + #[serde(rename = "UNLISTED")] + #[ts(rename = "UNLISTED")] + Unlisted, + #[serde(rename = "PRIVATE")] + #[ts(rename = "PRIVATE")] + Private, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginShareUpdateDiscoverability { + #[serde(rename = "UNLISTED")] + #[ts(rename = "UNLISTED")] + Unlisted, + #[serde(rename = "PRIVATE")] + #[ts(rename = "PRIVATE")] + Private, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginSharePrincipalType { + #[serde(rename = "user")] + #[ts(rename = "user")] + User, + #[serde(rename = "group")] + #[ts(rename = "group")] + Group, + #[serde(rename = "workspace")] + #[ts(rename = "workspace")] + Workspace, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareTarget { + pub principal_type: PluginSharePrincipalType, + pub principal_id: String, + pub role: PluginShareTargetRole, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginSharePrincipal { + pub principal_type: PluginSharePrincipalType, + pub principal_id: String, + pub role: PluginSharePrincipalRole, + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum PluginShareTargetRole { + Reader, + Editor, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(rename_all = "lowercase")] +#[ts(export_to = "v2/")] +pub enum PluginSharePrincipalRole { + Reader, + Editor, + Owner, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum SkillScope { + User, + Repo, + System, + Admin, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillMetadata { + pub name: String, + pub description: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + /// Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description. + pub short_description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub interface: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub dependencies: Option, + pub path: AbsolutePathBuf, + pub scope: SkillScope, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillInterface { + #[ts(optional)] + pub display_name: Option, + #[ts(optional)] + pub short_description: Option, + #[ts(optional)] + pub icon_small: Option, + #[ts(optional)] + pub icon_large: Option, + #[ts(optional)] + pub brand_color: Option, + #[ts(optional)] + pub default_prompt: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillDependencies { + pub tools: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillToolDependency { + #[serde(rename = "type")] + #[ts(rename = "type")] + pub r#type: String, + pub value: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub transport: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub command: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub url: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillErrorInfo { + pub path: PathBuf, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsListEntry { + pub cwd: PathBuf, + pub skills: Vec, + pub errors: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HooksListEntry { + pub cwd: PathBuf, + pub hooks: Vec, + pub warnings: Vec, + pub errors: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookMetadata { + pub key: String, + pub event_name: HookEventName, + pub handler_type: HookHandlerType, + pub matcher: Option, + pub command: Option, + pub timeout_sec: u64, + pub status_message: Option, + pub source_path: AbsolutePathBuf, + pub source: HookSource, + pub plugin_id: Option, + pub display_order: i64, + pub enabled: bool, + pub is_managed: bool, + pub current_hash: String, + pub trust_status: HookTrustStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct HookErrorInfo { + pub path: PathBuf, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginMarketplaceEntry { + pub name: String, + /// Local marketplace file path when the marketplace is backed by a local file. + /// Remote-only catalog marketplaces do not have a local path. + pub path: Option, + pub interface: Option, + pub plugins: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceInterface { + pub display_name: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginInstallPolicy { + #[serde(rename = "NOT_AVAILABLE")] + #[ts(rename = "NOT_AVAILABLE")] + NotAvailable, + #[serde(rename = "AVAILABLE")] + #[ts(rename = "AVAILABLE")] + Available, + #[serde(rename = "INSTALLED_BY_DEFAULT")] + #[ts(rename = "INSTALLED_BY_DEFAULT")] + InstalledByDefault, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginAuthPolicy { + #[serde(rename = "ON_INSTALL")] + #[ts(rename = "ON_INSTALL")] + OnInstall, + #[serde(rename = "ON_USE")] + #[ts(rename = "ON_USE")] + OnUse, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginAvailability { + /// Plugin-service currently sends `"ENABLED"` for available remote plugins. + /// Codex app-server exposes `"AVAILABLE"` in its API; the alias keeps + /// decoding compatible with that upstream response. + #[serde(rename = "AVAILABLE", alias = "ENABLED")] + #[ts(rename = "AVAILABLE")] + #[default] + Available, + #[serde(rename = "DISABLED_BY_ADMIN")] + #[ts(rename = "DISABLED_BY_ADMIN")] + DisabledByAdmin, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginSummary { + pub id: String, + /// Backend remote plugin identifier when available. + pub remote_plugin_id: Option, + /// Version of the locally materialized plugin package when available. + #[serde(default)] + pub local_version: Option, + pub name: String, + /// Remote sharing context associated with this plugin when available. + pub share_context: Option, + pub source: PluginSource, + pub installed: bool, + pub enabled: bool, + pub install_policy: PluginInstallPolicy, + pub auth_policy: PluginAuthPolicy, + /// Availability state for installing and using the plugin. + #[serde(default)] + pub availability: PluginAvailability, + pub interface: Option, + #[serde(default)] + pub keywords: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginShareContext { + pub remote_plugin_id: String, + /// Version of the remote shared plugin release when available. + #[serde(default)] + pub remote_version: Option, + pub discoverability: Option, + pub share_url: Option, + pub creator_account_user_id: Option, + pub creator_name: Option, + pub share_principals: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginDetail { + pub marketplace_name: String, + pub marketplace_path: Option, + pub summary: PluginSummary, + pub description: Option, + pub skills: Vec, + pub hooks: Vec, + pub apps: Vec, + pub mcp_servers: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginHookSummary { + pub key: String, + pub event_name: HookEventName, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillSummary { + pub name: String, + pub description: String, + pub short_description: Option, + pub interface: Option, + pub path: Option, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginInterface { + pub display_name: Option, + pub short_description: Option, + pub long_description: Option, + pub developer_name: Option, + pub category: Option, + pub capabilities: Vec, + pub website_url: Option, + pub privacy_policy_url: Option, + pub terms_of_service_url: Option, + /// Starter prompts for the plugin. Capped at 3 entries with a maximum of + /// 128 characters per entry. + pub default_prompt: Option>, + pub brand_color: Option, + /// Local composer icon path, resolved from the installed plugin package. + pub composer_icon: Option, + /// Remote composer icon URL from the plugin catalog. + pub composer_icon_url: Option, + /// Local logo path, resolved from the installed plugin package. + pub logo: Option, + /// Remote logo URL from the plugin catalog. + pub logo_url: Option, + /// Local screenshot paths, resolved from the installed plugin package. + pub screenshots: Vec, + /// Remote screenshot URLs from the plugin catalog. + pub screenshot_urls: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum PluginSource { + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Local { path: AbsolutePathBuf }, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Git { + url: String, + path: Option, + ref_name: Option, + sha: Option, + }, + /// The plugin is available in the remote catalog. Download metadata is + /// kept server-side and is not exposed through the app-server API. + Remote, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsConfigWriteParams { + /// Path-based selector. + #[ts(optional = nullable)] + pub path: Option, + /// Name-based selector. + #[ts(optional = nullable)] + pub name: Option, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct SkillsConfigWriteResponse { + pub effective_enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginInstallParams { + #[ts(optional = nullable)] + pub marketplace_path: Option, + #[ts(optional = nullable)] + pub remote_marketplace_name: Option, + pub plugin_name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginInstallResponse { + pub auth_policy: PluginAuthPolicy, + pub apps_needing_auth: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginUninstallParams { + pub plugin_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct PluginUninstallResponse {} + +impl From for SkillMetadata { + fn from(value: CoreSkillMetadata) -> Self { + Self { + name: value.name, + description: value.description, + short_description: value.short_description, + interface: value.interface.map(SkillInterface::from), + dependencies: value.dependencies.map(SkillDependencies::from), + path: value.path, + scope: value.scope.into(), + enabled: true, + } + } +} + +impl From for SkillInterface { + fn from(value: CoreSkillInterface) -> Self { + Self { + display_name: value.display_name, + short_description: value.short_description, + brand_color: value.brand_color, + default_prompt: value.default_prompt, + icon_small: value.icon_small, + icon_large: value.icon_large, + } + } +} + +impl From for SkillDependencies { + fn from(value: CoreSkillDependencies) -> Self { + Self { + tools: value + .tools + .into_iter() + .map(SkillToolDependency::from) + .collect(), + } + } +} + +impl From for SkillToolDependency { + fn from(value: CoreSkillToolDependency) -> Self { + Self { + r#type: value.r#type, + value: value.value, + description: value.description, + transport: value.transport, + command: value.command, + url: value.url, + } + } +} + +impl From for SkillScope { + fn from(value: CoreSkillScope) -> Self { + match value { + CoreSkillScope::User => Self::User, + CoreSkillScope::Repo => Self::Repo, + CoreSkillScope::System => Self::System, + CoreSkillScope::Admin => Self::Admin, + } + } +} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// Notification emitted when watched local skill files change. +/// +/// Treat this as an invalidation signal and re-run `skills/list` with the +/// client's current parameters when refreshed skill metadata is needed. +pub struct SkillsChangedNotification {} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/process.rs b/codex-rs/app-server-protocol/src/protocol/v2/process.rs new file mode 100644 index 0000000000..b70847165e --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/process.rs @@ -0,0 +1,204 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use ts_rs::TS; + +/// PTY size in character cells for `process/spawn` PTY sessions. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessTerminalSize { + /// Terminal height in character cells. + pub rows: u16, + /// Terminal width in character cells. + pub cols: u16, +} + +/// Spawn a standalone process (argv vector) without a Codex sandbox on the host +/// where the app server is running. +/// +/// `process/spawn` returns after the process has started and the connection-scoped +/// `processHandle` has been registered. Process output and exit are reported via +/// `process/outputDelta` and `process/exited` notifications. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessSpawnParams { + /// Command argv vector. Empty arrays are rejected. + pub command: Vec, + /// Client-supplied, connection-scoped process handle. + /// + /// Duplicate active handles are rejected on the same connection. The same + /// handle can be reused after the prior process exits. + pub process_handle: String, + /// Absolute working directory for the process. + pub cwd: AbsolutePathBuf, + /// Enable PTY mode. + /// + /// This implies `streamStdin` and `streamStdoutStderr`. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub tty: bool, + /// Allow follow-up `process/writeStdin` requests to write stdin bytes. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stream_stdin: bool, + /// Stream stdout/stderr via `process/outputDelta` notifications. + /// + /// Streamed bytes are not duplicated into the `process/exited` notification. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub stream_stdout_stderr: bool, + /// Optional per-stream stdout/stderr capture cap in bytes. + /// + /// When omitted, the server default applies. Set to `null` to disable the + /// cap. + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub output_bytes_cap: Option>, + /// Optional timeout in milliseconds. + /// + /// When omitted, the server default applies. Set to `null` to disable the + /// timeout. + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(type = "number | null")] + #[ts(optional = nullable)] + pub timeout_ms: Option>, + /// Optional environment overrides merged into the app-server process + /// environment. + /// + /// Matching names override inherited values. Set a key to `null` to unset + /// an inherited variable. + #[ts(optional = nullable)] + pub env: Option>>, + /// Optional initial PTY size in character cells. Only valid when `tty` is + /// true. + #[ts(optional = nullable)] + pub size: Option, +} + +/// Successful response for `process/spawn`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessSpawnResponse {} + +/// Write stdin bytes to a running `process/spawn` session, close stdin, or +/// both. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessWriteStdinParams { + /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. + pub process_handle: String, + /// Optional base64-encoded stdin bytes to write. + #[ts(optional = nullable)] + pub delta_base64: Option, + /// Close stdin after writing `deltaBase64`, if present. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub close_stdin: bool, +} + +/// Empty success response for `process/writeStdin`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessWriteStdinResponse {} + +/// Terminate a running `process/spawn` session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessKillParams { + /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. + pub process_handle: String, +} + +/// Empty success response for `process/kill`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessKillResponse {} + +/// Resize a running PTY-backed `process/spawn` session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessResizePtyParams { + /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. + pub process_handle: String, + /// New PTY size in character cells. + pub size: ProcessTerminalSize, +} + +/// Empty success response for `process/resizePty`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessResizePtyResponse {} + +/// Stream label for `process/outputDelta` notifications. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ProcessOutputStream { + /// stdout stream. PTY mode multiplexes terminal output here. + Stdout, + /// stderr stream. + Stderr, +} + +/// Base64-encoded output chunk emitted for a streaming `process/spawn` request. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessOutputDeltaNotification { + /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. + pub process_handle: String, + /// Output stream this chunk belongs to. + pub stream: ProcessOutputStream, + /// Base64-encoded output bytes. + pub delta_base64: String, + /// True on the final streamed chunk for this stream when output was + /// truncated by `outputBytesCap`. + pub cap_reached: bool, +} + +/// Final process exit notification for `process/spawn`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ProcessExitedNotification { + /// Client-supplied, connection-scoped `processHandle` from `process/spawn`. + pub process_handle: String, + /// Process exit code. + pub exit_code: i32, + /// Buffered stdout capture. + /// + /// Empty when stdout was streamed via `process/outputDelta`. + pub stdout: String, + /// Whether stdout reached `outputBytesCap`. + /// + /// In streaming mode, stdout is empty and cap state is also reported on the + /// final stdout `process/outputDelta` notification. + pub stdout_cap_reached: bool, + /// Buffered stderr capture. + /// + /// Empty when stderr was streamed via `process/outputDelta`. + pub stderr: String, + /// Whether stderr reached `outputBytesCap`. + /// + /// In streaming mode, stderr is empty and cap state is also reported on the + /// final stderr `process/outputDelta` notification. + pub stderr_cap_reached: bool, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs new file mode 100644 index 0000000000..c6ea0744de --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs @@ -0,0 +1,241 @@ +use codex_protocol::protocol::RealtimeAudioFrame as CoreRealtimeAudioFrame; +use codex_protocol::protocol::RealtimeConversationVersion; +use codex_protocol::protocol::RealtimeOutputModality; +use codex_protocol::protocol::RealtimeVoice; +use codex_protocol::protocol::RealtimeVoicesList; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use ts_rs::TS; + +/// EXPERIMENTAL - thread realtime audio chunk. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeAudioChunk { + pub data: String, + pub sample_rate: u32, + pub num_channels: u16, + pub samples_per_channel: Option, + pub item_id: Option, +} + +impl From for ThreadRealtimeAudioChunk { + fn from(value: CoreRealtimeAudioFrame) -> Self { + let CoreRealtimeAudioFrame { + data, + sample_rate, + num_channels, + samples_per_channel, + item_id, + } = value; + Self { + data, + sample_rate, + num_channels, + samples_per_channel, + item_id, + } + } +} + +impl From for CoreRealtimeAudioFrame { + fn from(value: ThreadRealtimeAudioChunk) -> Self { + let ThreadRealtimeAudioChunk { + data, + sample_rate, + num_channels, + samples_per_channel, + item_id, + } = value; + Self { + data, + sample_rate, + num_channels, + samples_per_channel, + item_id, + } + } +} + +/// EXPERIMENTAL - start a thread-scoped realtime session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeStartParams { + pub thread_id: String, + /// Selects text or audio output for the realtime session. Transport and voice stay + /// independent so clients can choose how they connect separately from what the model emits. + pub output_modality: RealtimeOutputModality, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub prompt: Option>, + #[ts(optional = nullable)] + pub realtime_session_id: Option, + #[ts(optional = nullable)] + pub transport: Option, + #[ts(optional = nullable)] + pub voice: Option, +} + +/// EXPERIMENTAL - transport used by thread realtime. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(export_to = "v2/", tag = "type")] +pub enum ThreadRealtimeStartTransport { + Websocket, + Webrtc { + /// SDP offer generated by a WebRTC RTCPeerConnection after configuring audio and the + /// realtime events data channel. + sdp: String, + }, +} + +/// EXPERIMENTAL - response for starting thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeStartResponse {} + +/// EXPERIMENTAL - append audio input to thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeAppendAudioParams { + pub thread_id: String, + pub audio: ThreadRealtimeAudioChunk, +} + +/// EXPERIMENTAL - response for appending realtime audio input. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeAppendAudioResponse {} + +/// EXPERIMENTAL - append text input to thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeAppendTextParams { + pub thread_id: String, + pub text: String, +} + +/// EXPERIMENTAL - response for appending realtime text input. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeAppendTextResponse {} + +/// EXPERIMENTAL - stop thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeStopParams { + pub thread_id: String, +} + +/// EXPERIMENTAL - response for stopping thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeStopResponse {} + +/// EXPERIMENTAL - list voices supported by thread realtime. +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeListVoicesParams {} + +/// EXPERIMENTAL - response for listing supported realtime voices. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeListVoicesResponse { + pub voices: RealtimeVoicesList, +} + +/// EXPERIMENTAL - emitted when thread realtime startup is accepted. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeStartedNotification { + pub thread_id: String, + pub realtime_session_id: Option, + pub version: RealtimeConversationVersion, +} + +/// EXPERIMENTAL - raw non-audio thread realtime item emitted by the backend. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeItemAddedNotification { + pub thread_id: String, + pub item: JsonValue, +} + +/// EXPERIMENTAL - flat transcript delta emitted whenever realtime +/// transcript text changes. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeTranscriptDeltaNotification { + pub thread_id: String, + pub role: String, + /// Live transcript delta from the realtime event. + pub delta: String, +} + +/// EXPERIMENTAL - final transcript text emitted when realtime completes +/// a transcript part. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeTranscriptDoneNotification { + pub thread_id: String, + pub role: String, + /// Final complete text for the transcript part. + pub text: String, +} + +/// EXPERIMENTAL - streamed output audio emitted by thread realtime. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeOutputAudioDeltaNotification { + pub thread_id: String, + pub audio: ThreadRealtimeAudioChunk, +} + +/// EXPERIMENTAL - emitted with the remote SDP for a WebRTC realtime session. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeSdpNotification { + pub thread_id: String, + pub sdp: String, +} + +/// EXPERIMENTAL - emitted when thread realtime encounters an error. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeErrorNotification { + pub thread_id: String, + pub message: String, +} + +/// EXPERIMENTAL - emitted when thread realtime transport closes. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRealtimeClosedNotification { + pub thread_id: String, + pub reason: Option, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs new file mode 100644 index 0000000000..7dab24c3d4 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs @@ -0,0 +1,24 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +/// Current remote-control connection status and remote identity exposed to clients. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlStatusChangedNotification { + pub status: RemoteControlConnectionStatus, + pub installation_id: String, + pub environment_id: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum RemoteControlConnectionStatus { + Disabled, + Connecting, + Connected, + Errored, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/review.rs b/codex-rs/app-server-protocol/src/protocol/v2/review.rs new file mode 100644 index 0000000000..82ec5b6f59 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/review.rs @@ -0,0 +1,65 @@ +use super::Turn; +use super::shared::v2_enum_from_core; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +v2_enum_from_core!( + pub enum ReviewDelivery from codex_protocol::protocol::ReviewDelivery { + Inline, Detached + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReviewStartParams { + pub thread_id: String, + pub target: ReviewTarget, + + /// Where to run the review: inline (default) on the current thread or + /// detached on a new thread (returned in `reviewThreadId`). + #[serde(default)] + #[ts(optional = nullable)] + pub delivery: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReviewStartResponse { + pub turn: Turn, + /// Identifies the thread where the review runs. + /// + /// For inline reviews, this is the original thread id. + /// For detached reviews, this is the id of the new review thread. + pub review_thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type", export_to = "v2/")] +pub enum ReviewTarget { + /// Review the working tree: staged, unstaged, and untracked files. + UncommittedChanges, + + /// Review changes between the current branch and the given base branch. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + BaseBranch { branch: String }, + + /// Review the changes introduced by a specific commit. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Commit { + sha: String, + /// Optional human-readable label (e.g., commit subject) for UIs. + title: Option, + }, + + /// Arbitrary instructions, equivalent to the old free-form prompt. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Custom { instructions: String }, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/shared.rs b/codex-rs/app-server-protocol/src/protocol/v2/shared.rs new file mode 100644 index 0000000000..9ec1fb80cb --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/shared.rs @@ -0,0 +1,316 @@ +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::config_types::ApprovalsReviewer as CoreApprovalsReviewer; +use codex_protocol::config_types::SandboxMode as CoreSandboxMode; +use codex_protocol::protocol::AskForApproval as CoreAskForApproval; +use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; +use codex_protocol::protocol::GranularApprovalConfig as CoreGranularApprovalConfig; +use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind; +use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::InstanceType; +use schemars::schema::Metadata; +use schemars::schema::Schema; +use schemars::schema::SchemaObject; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use ts_rs::TS; + +// Macro to declare a camelCased API v2 enum mirroring a core enum which +// tends to use either snake_case or kebab-case. +macro_rules! v2_enum_from_core { + ( + $(#[$enum_meta:meta])* + pub enum $Name:ident from $Src:path { + $( $(#[$variant_meta:meta])* $Variant:ident ),+ $(,)? + } + ) => { + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] + $(#[$enum_meta])* + #[serde(rename_all = "camelCase")] + #[ts(export_to = "v2/")] + pub enum $Name { + $( $(#[$variant_meta])* $Variant ),+ + } + + impl $Name { + pub fn to_core(self) -> $Src { + match self { $( $Name::$Variant => <$Src>::$Variant ),+ } + } + } + + impl From<$Src> for $Name { + fn from(value: $Src) -> Self { + match value { $( <$Src>::$Variant => $Name::$Variant ),+ } + } + } + }; +} + +pub(super) use v2_enum_from_core; + +pub(super) const fn default_enabled() -> bool { + true +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum NonSteerableTurnKind { + Review, + Compact, +} + +/// This translation layer make sure that we expose codex error code in camel case. +/// +/// When an upstream HTTP status is available (for example, from the Responses API or a provider), +/// it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CodexErrorInfo { + ContextWindowExceeded, + UsageLimitExceeded, + ServerOverloaded, + CyberPolicy, + HttpConnectionFailed { + #[serde(rename = "httpStatusCode")] + #[ts(rename = "httpStatusCode")] + http_status_code: Option, + }, + /// Failed to connect to the response SSE stream. + ResponseStreamConnectionFailed { + #[serde(rename = "httpStatusCode")] + #[ts(rename = "httpStatusCode")] + http_status_code: Option, + }, + InternalServerError, + Unauthorized, + BadRequest, + ThreadRollbackFailed, + SandboxError, + /// The response SSE stream disconnected in the middle of a turn before completion. + ResponseStreamDisconnected { + #[serde(rename = "httpStatusCode")] + #[ts(rename = "httpStatusCode")] + http_status_code: Option, + }, + /// Reached the retry limit for responses. + ResponseTooManyFailedAttempts { + #[serde(rename = "httpStatusCode")] + #[ts(rename = "httpStatusCode")] + http_status_code: Option, + }, + /// Returned when `turn/start` or `turn/steer` is submitted while the current active turn + /// cannot accept same-turn steering, for example `/review` or manual `/compact`. + ActiveTurnNotSteerable { + #[serde(rename = "turnKind")] + #[ts(rename = "turnKind")] + turn_kind: NonSteerableTurnKind, + }, + Other, +} + +impl From for CodexErrorInfo { + fn from(value: CoreCodexErrorInfo) -> Self { + match value { + CoreCodexErrorInfo::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded, + CoreCodexErrorInfo::UsageLimitExceeded => CodexErrorInfo::UsageLimitExceeded, + CoreCodexErrorInfo::ServerOverloaded => CodexErrorInfo::ServerOverloaded, + CoreCodexErrorInfo::CyberPolicy => CodexErrorInfo::CyberPolicy, + CoreCodexErrorInfo::HttpConnectionFailed { http_status_code } => { + CodexErrorInfo::HttpConnectionFailed { http_status_code } + } + CoreCodexErrorInfo::ResponseStreamConnectionFailed { http_status_code } => { + CodexErrorInfo::ResponseStreamConnectionFailed { http_status_code } + } + CoreCodexErrorInfo::InternalServerError => CodexErrorInfo::InternalServerError, + CoreCodexErrorInfo::Unauthorized => CodexErrorInfo::Unauthorized, + CoreCodexErrorInfo::BadRequest => CodexErrorInfo::BadRequest, + CoreCodexErrorInfo::ThreadRollbackFailed => CodexErrorInfo::ThreadRollbackFailed, + CoreCodexErrorInfo::SandboxError => CodexErrorInfo::SandboxError, + CoreCodexErrorInfo::ResponseStreamDisconnected { http_status_code } => { + CodexErrorInfo::ResponseStreamDisconnected { http_status_code } + } + CoreCodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } => { + CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code } + } + CoreCodexErrorInfo::ActiveTurnNotSteerable { turn_kind } => { + CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + } + } + CoreCodexErrorInfo::Other => CodexErrorInfo::Other, + } + } +} + +impl From for NonSteerableTurnKind { + fn from(value: CoreNonSteerableTurnKind) -> Self { + match value { + CoreNonSteerableTurnKind::Review => Self::Review, + CoreNonSteerableTurnKind::Compact => Self::Compact, + } + } +} + +#[derive( + Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum AskForApproval { + #[serde(rename = "untrusted")] + #[ts(rename = "untrusted")] + UnlessTrusted, + OnFailure, + OnRequest, + #[experimental("askForApproval.granular")] + Granular { + sandbox_approval: bool, + rules: bool, + #[serde(default)] + skill_approval: bool, + #[serde(default)] + request_permissions: bool, + mcp_elicitations: bool, + }, + Never, +} + +impl AskForApproval { + pub fn to_core(self) -> CoreAskForApproval { + match self { + AskForApproval::UnlessTrusted => CoreAskForApproval::UnlessTrusted, + AskForApproval::OnFailure => CoreAskForApproval::OnFailure, + AskForApproval::OnRequest => CoreAskForApproval::OnRequest, + AskForApproval::Granular { + sandbox_approval, + rules, + skill_approval, + request_permissions, + mcp_elicitations, + } => CoreAskForApproval::Granular(CoreGranularApprovalConfig { + sandbox_approval, + rules, + skill_approval, + request_permissions, + mcp_elicitations, + }), + AskForApproval::Never => CoreAskForApproval::Never, + } + } +} + +impl From for AskForApproval { + fn from(value: CoreAskForApproval) -> Self { + match value { + CoreAskForApproval::UnlessTrusted => AskForApproval::UnlessTrusted, + CoreAskForApproval::OnFailure => AskForApproval::OnFailure, + CoreAskForApproval::OnRequest => AskForApproval::OnRequest, + CoreAskForApproval::Granular(granular_config) => AskForApproval::Granular { + sandbox_approval: granular_config.sandbox_approval, + rules: granular_config.rules, + skill_approval: granular_config.skill_approval, + request_permissions: granular_config.request_permissions, + mcp_elicitations: granular_config.mcp_elicitations, + }, + CoreAskForApproval::Never => AskForApproval::Never, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, TS)] +#[ts( + type = r#""user" | "auto_review" | "guardian_subagent""#, + export_to = "v2/" +)] +/// Configures who approval requests are routed to for review. Examples +/// include sandbox escapes, blocked network access, MCP approval prompts, and +/// ARC escalations. Defaults to `user`. `auto_review` uses a carefully +/// prompted subagent to gather relevant context and apply a risk-based +/// decision framework before approving or denying the request. +pub enum ApprovalsReviewer { + #[serde(rename = "user")] + User, + #[serde(rename = "guardian_subagent", alias = "auto_review")] + AutoReview, +} + +impl JsonSchema for ApprovalsReviewer { + fn schema_name() -> String { + "ApprovalsReviewer".to_string() + } + + fn json_schema(_generator: &mut SchemaGenerator) -> Schema { + string_enum_schema_with_description( + &["user", "auto_review", "guardian_subagent"], + "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + ) + } +} + +fn string_enum_schema_with_description(values: &[&str], description: &str) -> Schema { + let mut schema = SchemaObject { + instance_type: Some(InstanceType::String.into()), + metadata: Some(Box::new(Metadata { + description: Some(description.to_string()), + ..Default::default() + })), + ..Default::default() + }; + schema.enum_values = Some( + values + .iter() + .map(|value| JsonValue::String((*value).to_string())) + .collect(), + ); + Schema::Object(schema) +} + +impl ApprovalsReviewer { + pub fn to_core(self) -> CoreApprovalsReviewer { + match self { + ApprovalsReviewer::User => CoreApprovalsReviewer::User, + ApprovalsReviewer::AutoReview => CoreApprovalsReviewer::AutoReview, + } + } +} + +impl From for ApprovalsReviewer { + fn from(value: CoreApprovalsReviewer) -> Self { + match value { + CoreApprovalsReviewer::User => ApprovalsReviewer::User, + CoreApprovalsReviewer::AutoReview => ApprovalsReviewer::AutoReview, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(rename_all = "kebab-case", export_to = "v2/")] +pub enum SandboxMode { + ReadOnly, + WorkspaceWrite, + DangerFullAccess, +} + +impl SandboxMode { + pub fn to_core(self) -> CoreSandboxMode { + match self { + SandboxMode::ReadOnly => CoreSandboxMode::ReadOnly, + SandboxMode::WorkspaceWrite => CoreSandboxMode::WorkspaceWrite, + SandboxMode::DangerFullAccess => CoreSandboxMode::DangerFullAccess, + } + } +} + +impl From for SandboxMode { + fn from(value: CoreSandboxMode) -> Self { + match value { + CoreSandboxMode::ReadOnly => SandboxMode::ReadOnly, + CoreSandboxMode::WorkspaceWrite => SandboxMode::WorkspaceWrite, + CoreSandboxMode::DangerFullAccess => SandboxMode::DangerFullAccess, + } + } +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs new file mode 100644 index 0000000000..6e93fffd2c --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -0,0 +1,3546 @@ +use super::*; +use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; +use codex_protocol::items::AgentMessageContent; +use codex_protocol::items::AgentMessageItem; +use codex_protocol::items::FileChangeItem; +use codex_protocol::items::ImageViewItem; +use codex_protocol::items::McpToolCallItem; +use codex_protocol::items::McpToolCallStatus as CoreMcpToolCallStatus; +use codex_protocol::items::ReasoningItem; +use codex_protocol::items::TurnItem; +use codex_protocol::items::UserMessageItem; +use codex_protocol::items::WebSearchItem; +use codex_protocol::mcp::CallToolResult; +use codex_protocol::memory_citation::MemoryCitation as CoreMemoryCitation; +use codex_protocol::memory_citation::MemoryCitationEntry as CoreMemoryCitationEntry; +use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; +use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; +use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; +use codex_protocol::models::MessagePhase; +use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; +use codex_protocol::models::WebSearchAction as CoreWebSearchAction; +use codex_protocol::permissions::FileSystemAccessMode as CoreFileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath as CoreFileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry as CoreFileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSpecialPath as CoreFileSystemSpecialPath; +use codex_protocol::protocol::AgentStatus as CoreAgentStatus; +use codex_protocol::protocol::AskForApproval as CoreAskForApproval; +use codex_protocol::protocol::GranularApprovalConfig as CoreGranularApprovalConfig; +use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; +use codex_protocol::request_permissions::RequestPermissionProfile as CoreRequestPermissionProfile; +use codex_protocol::user_input::UserInput as CoreUserInput; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_absolute_path::test_support::PathBufExt; +use codex_utils_absolute_path::test_support::test_path_buf; +use pretty_assertions::assert_eq; +use serde_json::Value as JsonValue; +use serde_json::json; +use std::collections::BTreeMap; +use std::collections::HashMap; +use std::num::NonZeroUsize; +use std::path::PathBuf; +use std::time::Duration; + +fn absolute_path_string(path: &str) -> String { + let path = format!("/{}", path.trim_start_matches('/')); + test_path_buf(&path).display().to_string() +} + +fn absolute_path(path: &str) -> AbsolutePathBuf { + let path = format!("/{}", path.trim_start_matches('/')); + test_path_buf(&path).abs() +} + +fn test_absolute_path() -> AbsolutePathBuf { + absolute_path("readable") +} + +#[test] +fn approvals_reviewer_serializes_auto_review_and_accepts_legacy_guardian_subagent() { + assert_eq!( + serde_json::to_string(&ApprovalsReviewer::User).expect("serialize reviewer"), + "\"user\"" + ); + assert_eq!( + serde_json::to_string(&ApprovalsReviewer::AutoReview).expect("serialize reviewer"), + "\"guardian_subagent\"" + ); + + for value in ["user", "auto_review", "guardian_subagent"] { + let json = format!("\"{value}\""); + let reviewer: ApprovalsReviewer = + serde_json::from_str(&json).expect("deserialize reviewer"); + let expected = if value == "user" { + ApprovalsReviewer::User + } else { + ApprovalsReviewer::AutoReview + }; + assert_eq!(expected, reviewer); + } +} + +#[test] +fn turn_defaults_legacy_missing_items_view_to_full() { + let turn: Turn = serde_json::from_value(json!({ + "id": "turn_123", + "items": [], + "status": "completed", + "error": null, + "startedAt": null, + "completedAt": null, + "durationMs": null, + })) + .expect("legacy turn should deserialize"); + + assert_eq!(turn.items_view, TurnItemsView::Full); +} + +#[test] +fn thread_turns_list_params_accepts_items_view() { + let params = serde_json::from_value::(json!({ + "threadId": "thr_123", + "cursor": null, + "limit": 25, + "sortDirection": "desc", + "itemsView": "notLoaded", + })) + .expect("thread turns list params should deserialize"); + + assert_eq!(params.thread_id, "thr_123"); + assert_eq!(params.items_view, Some(TurnItemsView::NotLoaded)); +} + +#[test] +fn thread_turns_items_list_round_trips() { + let params = ThreadTurnsItemsListParams { + thread_id: "thr_123".to_string(), + turn_id: "turn_456".to_string(), + cursor: Some("cursor_1".to_string()), + limit: Some(50), + sort_direction: Some(SortDirection::Asc), + }; + + assert_eq!( + serde_json::to_value(¶ms).expect("serialize params"), + json!({ + "threadId": "thr_123", + "turnId": "turn_456", + "cursor": "cursor_1", + "limit": 50, + "sortDirection": "asc", + }) + ); + let response = ThreadTurnsItemsListResponse { + data: vec![ThreadItem::ContextCompaction { + id: "item_1".to_string(), + }], + next_cursor: None, + backwards_cursor: Some("cursor_0".to_string()), + }; + + assert_eq!( + serde_json::to_value(&response).expect("serialize response"), + json!({ + "data": [{"type": "contextCompaction", "id": "item_1"}], + "nextCursor": null, + "backwardsCursor": "cursor_0", + }) + ); +} + +#[test] +fn thread_list_params_accepts_single_cwd() { + let params = serde_json::from_value::(json!({ + "cwd": "/workspace", + })) + .expect("single cwd should deserialize"); + + assert_eq!( + params.cwd, + Some(ThreadListCwdFilter::One("/workspace".to_string())) + ); + assert!(!params.use_state_db_only); +} + +#[test] +fn thread_list_params_accepts_multiple_cwds() { + let params = serde_json::from_value::(json!({ + "cwd": ["/workspace", "/other-workspace"], + })) + .expect("cwd array should deserialize"); + + assert_eq!( + params.cwd, + Some(ThreadListCwdFilter::Many(vec![ + "/workspace".to_string(), + "/other-workspace".to_string(), + ])) + ); +} + +#[test] +fn thread_list_params_accepts_state_db_only_flag() { + let params = serde_json::from_value::(json!({ + "useStateDbOnly": true, + })) + .expect("state db only flag should deserialize"); + + assert!(params.use_state_db_only); +} + +#[test] +fn collab_agent_state_maps_interrupted_status() { + assert_eq!( + CollabAgentState::from(CoreAgentStatus::Interrupted), + CollabAgentState { + status: CollabAgentStatus::Interrupted, + message: None, + } + ); +} + +#[test] +fn external_agent_config_plugins_details_round_trip() { + let item: ExternalAgentConfigMigrationItem = serde_json::from_value(json!({ + "itemType": "PLUGINS", + "description": "Install supported plugins from Claude settings", + "cwd": absolute_path_string("repo"), + "details": { + "plugins": [ + { + "marketplaceName": "team-marketplace", + "pluginNames": ["asana"] + } + ] + } + })) + .expect("plugins migration item should deserialize"); + + assert_eq!( + item, + ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: "Install supported plugins from Claude settings".to_string(), + cwd: Some(PathBuf::from(absolute_path_string("repo"))), + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "team-marketplace".to_string(), + plugin_names: vec!["asana".to_string()], + }], + ..Default::default() + }), + } + ); +} + +#[test] +fn external_agent_config_import_params_accept_legacy_plugin_details() { + let params: ExternalAgentConfigImportParams = serde_json::from_value(json!({ + "migrationItems": [{ + "itemType": "PLUGINS", + "description": "Install supported plugins from Claude settings", + "cwd": absolute_path_string("repo"), + "details": { + "plugins": [ + { + "marketplaceName": "team-marketplace", + "pluginNames": ["asana"] + } + ] + } + }] + })) + .expect("legacy plugin import params should deserialize"); + + assert_eq!( + params, + ExternalAgentConfigImportParams { + migration_items: vec![ExternalAgentConfigMigrationItem { + item_type: ExternalAgentConfigMigrationItemType::Plugins, + description: "Install supported plugins from Claude settings".to_string(), + cwd: Some(PathBuf::from(absolute_path_string("repo"))), + details: Some(MigrationDetails { + plugins: vec![PluginsMigration { + marketplace_name: "team-marketplace".to_string(), + plugin_names: vec!["asana".to_string()], + }], + ..Default::default() + }), + }], + } + ); +} + +#[test] +fn command_execution_request_approval_rejects_relative_additional_permission_paths() { + let err = serde_json::from_value::(json!({ + "threadId": "thr_123", + "turnId": "turn_123", + "itemId": "call_123", + "startedAtMs": 1, + "command": "cat file", + "cwd": absolute_path_string("tmp"), + "commandActions": null, + "reason": null, + "networkApprovalContext": null, + "additionalPermissions": { + "network": null, + "fileSystem": { + "read": ["relative/path"], + "write": null + } + }, + "proposedExecpolicyAmendment": null, + "proposedNetworkPolicyAmendments": null, + "availableDecisions": null + })) + .expect_err("relative additional permission paths should fail"); + assert!( + err.to_string() + .contains("AbsolutePathBuf deserialized without a base path"), + "unexpected error: {err}" + ); +} + +#[test] +fn permissions_request_approval_uses_request_permission_profile() { + let read_only_path = if cfg!(windows) { + r"C:\tmp\read-only" + } else { + "/tmp/read-only" + }; + let read_write_path = if cfg!(windows) { + r"C:\tmp\read-write" + } else { + "/tmp/read-write" + }; + let params = serde_json::from_value::(json!({ + "threadId": "thr_123", + "turnId": "turn_123", + "itemId": "call_123", + "startedAtMs": 1, + "cwd": absolute_path_string("repo"), + "reason": "Select a workspace root", + "permissions": { + "network": { + "enabled": true, + }, + "fileSystem": { + "read": [read_only_path], + "write": [read_write_path], + }, + }, + })) + .expect("permissions request should deserialize"); + + assert_eq!(params.cwd, absolute_path("repo")); + assert_eq!( + params.permissions, + RequestPermissionProfile { + network: Some(AdditionalNetworkPermissions { + enabled: Some(true), + }), + file_system: Some(AdditionalFileSystemPermissions { + read: Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) + .expect("path must be absolute"), + ]), + write: Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) + .expect("path must be absolute"), + ]), + glob_scan_max_depth: None, + entries: None, + }), + } + ); + + assert_eq!( + CoreRequestPermissionProfile::from(params.permissions), + CoreRequestPermissionProfile { + network: Some(CoreNetworkPermissions { + enabled: Some(true), + }), + file_system: Some(CoreFileSystemPermissions::from_read_write_roots( + Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) + .expect("path must be absolute"), + ]), + Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) + .expect("path must be absolute"), + ]), + )), + } + ); +} + +#[test] +fn permissions_request_approval_rejects_macos_permissions() { + let err = serde_json::from_value::(json!({ + "threadId": "thr_123", + "turnId": "turn_123", + "itemId": "call_123", + "startedAtMs": 1, + "cwd": absolute_path_string("repo"), + "reason": "Select a workspace root", + "permissions": { + "network": null, + "fileSystem": null, + "macos": { + "preferences": "read_only", + "automations": "none", + "launchServices": false, + "accessibility": false, + "calendar": false, + "reminders": false, + "contacts": "none", + }, + }, + })) + .expect_err("permissions request should reject macos permissions"); + + assert!( + err.to_string().contains("unknown field `macos`"), + "unexpected error: {err}" + ); +} + +#[test] +fn additional_file_system_permissions_preserves_canonical_entries() { + let core_permissions = CoreFileSystemPermissions { + entries: vec![ + CoreFileSystemSandboxEntry { + path: CoreFileSystemPath::Special { + value: CoreFileSystemSpecialPath::Root, + }, + access: CoreFileSystemAccessMode::Write, + }, + CoreFileSystemSandboxEntry { + path: CoreFileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: CoreFileSystemAccessMode::None, + }, + ], + glob_scan_max_depth: NonZeroUsize::new(2), + }; + + let permissions = AdditionalFileSystemPermissions::from(core_permissions.clone()); + assert_eq!( + permissions, + AdditionalFileSystemPermissions { + read: None, + write: None, + glob_scan_max_depth: NonZeroUsize::new(2), + entries: Some(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: FileSystemAccessMode::None, + }, + ]), + } + ); + assert_eq!( + CoreFileSystemPermissions::from(permissions), + core_permissions + ); +} + +#[test] +fn additional_file_system_permissions_populates_entries_for_legacy_roots() { + let read_only_path = absolute_path("read-only"); + let read_write_path = absolute_path("read-write"); + let core_permissions = CoreFileSystemPermissions::from_read_write_roots( + Some(vec![read_only_path.clone()]), + Some(vec![read_write_path.clone()]), + ); + + let permissions = AdditionalFileSystemPermissions::from(core_permissions.clone()); + + assert_eq!( + permissions, + AdditionalFileSystemPermissions { + read: Some(vec![read_only_path.clone()]), + write: Some(vec![read_write_path.clone()]), + glob_scan_max_depth: None, + entries: Some(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: read_only_path, + }, + access: FileSystemAccessMode::Read, + }, + FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: read_write_path, + }, + access: FileSystemAccessMode::Write, + }, + ]), + } + ); + assert_eq!( + CoreFileSystemPermissions::from(permissions), + core_permissions + ); +} + +#[test] +fn additional_file_system_permissions_rejects_zero_glob_scan_depth() { + serde_json::from_value::(json!({ + "read": null, + "write": null, + "globScanMaxDepth": 0, + "entries": [], + })) + .expect_err("zero glob scan depth should fail deserialization"); +} + +#[test] +fn permission_profile_file_system_permissions_preserves_glob_scan_depth() { + let core_permissions = CoreManagedFileSystemPermissions::Restricted { + entries: vec![CoreFileSystemSandboxEntry { + path: CoreFileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: CoreFileSystemAccessMode::None, + }], + glob_scan_max_depth: NonZeroUsize::new(2), + }; + + let permissions = PermissionProfileFileSystemPermissions::from(core_permissions.clone()); + + assert_eq!( + permissions, + PermissionProfileFileSystemPermissions::Restricted { + entries: vec![FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "**/*.env".to_string(), + }, + access: FileSystemAccessMode::None, + }], + glob_scan_max_depth: NonZeroUsize::new(2), + } + ); + assert_eq!( + CoreManagedFileSystemPermissions::from(permissions), + core_permissions + ); +} + +#[test] +fn permission_profile_file_system_permissions_rejects_zero_glob_scan_depth() { + serde_json::from_value::(json!({ + "type": "restricted", + "entries": [], + "globScanMaxDepth": 0, + })) + .expect_err("zero glob scan depth should fail deserialization"); +} + +#[test] +fn legacy_current_working_directory_special_path_deserializes_as_project_roots() { + let special_path = serde_json::from_value::(json!({ + "kind": "current_working_directory", + })) + .expect("legacy cwd special path should deserialize"); + + assert_eq!( + special_path, + FileSystemSpecialPath::ProjectRoots { subpath: None } + ); + assert_eq!( + serde_json::to_value(&special_path).expect("serialize special path"), + json!({ + "kind": "project_roots", + "subpath": null, + }) + ); +} + +#[test] +fn permissions_request_approval_response_uses_granted_permission_profile_without_macos() { + let read_only_path = if cfg!(windows) { + r"C:\tmp\read-only" + } else { + "/tmp/read-only" + }; + let read_write_path = if cfg!(windows) { + r"C:\tmp\read-write" + } else { + "/tmp/read-write" + }; + let response = serde_json::from_value::(json!({ + "permissions": { + "network": { + "enabled": true, + }, + "fileSystem": { + "read": [read_only_path], + "write": [read_write_path], + }, + }, + })) + .expect("permissions response should deserialize"); + + assert_eq!( + response.permissions, + GrantedPermissionProfile { + network: Some(AdditionalNetworkPermissions { + enabled: Some(true), + }), + file_system: Some(AdditionalFileSystemPermissions { + read: Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) + .expect("path must be absolute"), + ]), + write: Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) + .expect("path must be absolute"), + ]), + glob_scan_max_depth: None, + entries: None, + }), + } + ); + + assert_eq!( + CoreAdditionalPermissionProfile::from(response.permissions), + CoreAdditionalPermissionProfile { + network: Some(CoreNetworkPermissions { + enabled: Some(true), + }), + file_system: Some(CoreFileSystemPermissions::from_read_write_roots( + Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_only_path)) + .expect("path must be absolute"), + ]), + Some(vec![ + AbsolutePathBuf::try_from(PathBuf::from(read_write_path)) + .expect("path must be absolute"), + ]), + )), + } + ); +} + +#[test] +fn permissions_request_approval_response_defaults_scope_to_turn() { + let response = serde_json::from_value::(json!({ + "permissions": {}, + })) + .expect("response should deserialize"); + + assert_eq!(response.scope, PermissionGrantScope::Turn); + assert_eq!(response.strict_auto_review, None); +} + +#[test] +fn permissions_request_approval_response_accepts_strict_auto_review() { + let response = serde_json::from_value::(json!({ + "permissions": {}, + "strictAutoReview": true, + })) + .expect("response should deserialize"); + + assert_eq!(response.strict_auto_review, Some(true)); +} + +#[test] +fn fs_get_metadata_response_round_trips_minimal_fields() { + let response = FsGetMetadataResponse { + is_directory: false, + is_file: true, + is_symlink: false, + created_at_ms: 123, + modified_at_ms: 456, + }; + + let value = serde_json::to_value(&response).expect("serialize fs/getMetadata response"); + assert_eq!( + value, + json!({ + "isDirectory": false, + "isFile": true, + "isSymlink": false, + "createdAtMs": 123, + "modifiedAtMs": 456, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize fs/getMetadata response"); + assert_eq!(decoded, response); +} + +#[test] +fn fs_read_file_response_round_trips_base64_data() { + let response = FsReadFileResponse { + data_base64: "aGVsbG8=".to_string(), + }; + + let value = serde_json::to_value(&response).expect("serialize fs/readFile response"); + assert_eq!( + value, + json!({ + "dataBase64": "aGVsbG8=", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize fs/readFile response"); + assert_eq!(decoded, response); +} + +#[test] +fn fs_read_file_params_round_trip() { + let params = FsReadFileParams { + path: absolute_path("tmp/example.txt"), + }; + + let value = serde_json::to_value(¶ms).expect("serialize fs/readFile params"); + assert_eq!( + value, + json!({ + "path": absolute_path_string("tmp/example.txt"), + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize fs/readFile params"); + assert_eq!(decoded, params); +} + +#[test] +fn fs_create_directory_params_round_trip_with_default_recursive() { + let params = FsCreateDirectoryParams { + path: absolute_path("tmp/example"), + recursive: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize fs/createDirectory params"); + assert_eq!( + value, + json!({ + "path": absolute_path_string("tmp/example"), + "recursive": null, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize fs/createDirectory params"); + assert_eq!(decoded, params); +} + +#[test] +fn fs_write_file_params_round_trip_with_base64_data() { + let params = FsWriteFileParams { + path: absolute_path("tmp/example.bin"), + data_base64: "AAE=".to_string(), + }; + + let value = serde_json::to_value(¶ms).expect("serialize fs/writeFile params"); + assert_eq!( + value, + json!({ + "path": absolute_path_string("tmp/example.bin"), + "dataBase64": "AAE=", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize fs/writeFile params"); + assert_eq!(decoded, params); +} + +#[test] +fn fs_copy_params_round_trip_with_recursive_directory_copy() { + let params = FsCopyParams { + source_path: absolute_path("tmp/source"), + destination_path: absolute_path("tmp/destination"), + recursive: true, + }; + + let value = serde_json::to_value(¶ms).expect("serialize fs/copy params"); + assert_eq!( + value, + json!({ + "sourcePath": absolute_path_string("tmp/source"), + "destinationPath": absolute_path_string("tmp/destination"), + "recursive": true, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize fs/copy params"); + assert_eq!(decoded, params); +} + +#[test] +fn thread_shell_command_params_round_trip() { + let params = ThreadShellCommandParams { + thread_id: "thr_123".to_string(), + command: "printf 'hello world\\n'".to_string(), + }; + + let value = serde_json::to_value(¶ms).expect("serialize thread/shellCommand params"); + assert_eq!( + value, + json!({ + "threadId": "thr_123", + "command": "printf 'hello world\\n'", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize thread/shellCommand params"); + assert_eq!(decoded, params); +} + +#[test] +fn thread_shell_command_response_round_trip() { + let response = ThreadShellCommandResponse {}; + + let value = serde_json::to_value(&response).expect("serialize thread/shellCommand response"); + assert_eq!(value, json!({})); + + let decoded = serde_json::from_value::(value) + .expect("deserialize thread/shellCommand response"); + assert_eq!(decoded, response); +} + +#[test] +fn fs_changed_notification_round_trips() { + let notification = FsChangedNotification { + watch_id: "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1".to_string(), + changed_paths: vec![ + absolute_path("tmp/repo/.git/HEAD"), + absolute_path("tmp/repo/.git/FETCH_HEAD"), + ], + }; + + let value = serde_json::to_value(¬ification).expect("serialize fs/changed notification"); + assert_eq!( + value, + json!({ + "watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1", + "changedPaths": [ + absolute_path_string("tmp/repo/.git/HEAD"), + absolute_path_string("tmp/repo/.git/FETCH_HEAD"), + ], + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize fs/changed notification"); + assert_eq!(decoded, notification); +} + +#[test] +fn command_exec_params_default_optional_streaming_flags() { + let params = serde_json::from_value::(json!({ + "command": ["ls", "-la"], + "timeoutMs": 1000, + "cwd": "/tmp" + })) + .expect("command/exec payload should deserialize"); + + assert_eq!( + params, + CommandExecParams { + command: vec!["ls".to_string(), "-la".to_string()], + process_id: None, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: Some(1000), + cwd: Some(PathBuf::from("/tmp")), + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + } + ); +} + +#[test] +fn command_exec_params_round_trips_disable_timeout() { + let params = CommandExecParams { + command: vec!["sleep".to_string(), "30".to_string()], + process_id: Some("sleep-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: true, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["sleep", "30"], + "processId": "sleep-1", + "disableTimeout": true, + "timeoutMs": null, + "cwd": null, + "env": null, + "size": null, + "sandboxPolicy": null, + "permissionProfile": null, + "outputBytesCap": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn process_spawn_params_round_trips_without_sandbox_policy() { + let params = ProcessSpawnParams { + command: vec!["sleep".to_string(), "30".to_string()], + process_handle: "sleep-1".to_string(), + cwd: test_absolute_path(), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + timeout_ms: None, + env: None, + size: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize process/spawn params"); + assert_eq!( + value, + json!({ + "command": ["sleep", "30"], + "processHandle": "sleep-1", + "cwd": absolute_path_string("readable"), + "env": null, + "size": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn process_spawn_params_distinguish_omitted_null_and_value_limits() { + let base = json!({ + "command": ["sleep", "30"], + "processHandle": "sleep-1", + "cwd": absolute_path_string("readable"), + }); + + let expected_omitted = ProcessSpawnParams { + command: vec!["sleep".to_string(), "30".to_string()], + process_handle: "sleep-1".to_string(), + cwd: test_absolute_path(), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + timeout_ms: None, + env: None, + size: None, + }; + let decoded = + serde_json::from_value::(base).expect("deserialize omitted limits"); + assert_eq!(decoded, expected_omitted); + + let decoded = serde_json::from_value::(json!({ + "command": ["sleep", "30"], + "processHandle": "sleep-1", + "cwd": absolute_path_string("readable"), + "outputBytesCap": null, + "timeoutMs": null, + })) + .expect("deserialize disabled limits"); + assert_eq!( + decoded, + ProcessSpawnParams { + output_bytes_cap: Some(None), + timeout_ms: Some(None), + ..expected_omitted.clone() + } + ); + + let decoded = serde_json::from_value::(json!({ + "command": ["sleep", "30"], + "processHandle": "sleep-1", + "cwd": absolute_path_string("readable"), + "outputBytesCap": 123, + "timeoutMs": 456, + })) + .expect("deserialize explicit limits"); + assert_eq!( + decoded, + ProcessSpawnParams { + output_bytes_cap: Some(Some(123)), + timeout_ms: Some(Some(456)), + ..expected_omitted + } + ); +} + +#[test] +fn command_exec_params_round_trips_disable_output_cap() { + let params = CommandExecParams { + command: vec!["yes".to_string()], + process_id: Some("yes-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: true, + output_bytes_cap: None, + disable_output_cap: true, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: None, + sandbox_policy: None, + permission_profile: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["yes"], + "processId": "yes-1", + "streamStdoutStderr": true, + "outputBytesCap": null, + "disableOutputCap": true, + "timeoutMs": null, + "cwd": null, + "env": null, + "size": null, + "sandboxPolicy": null, + "permissionProfile": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_params_round_trips_env_overrides_and_unsets() { + let params = CommandExecParams { + command: vec!["printenv".to_string(), "FOO".to_string()], + process_id: Some("env-1".to_string()), + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: Some(HashMap::from([ + ("FOO".to_string(), Some("override".to_string())), + ("BAR".to_string(), Some("added".to_string())), + ("BAZ".to_string(), None), + ])), + size: None, + sandbox_policy: None, + permission_profile: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["printenv", "FOO"], + "processId": "env-1", + "outputBytesCap": null, + "timeoutMs": null, + "cwd": null, + "env": { + "FOO": "override", + "BAR": "added", + "BAZ": null, + }, + "size": null, + "sandboxPolicy": null, + "permissionProfile": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_write_round_trips_close_only_payload() { + let params = CommandExecWriteParams { + process_id: "proc-7".to_string(), + delta_base64: None, + close_stdin: true, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec/write params"); + assert_eq!( + value, + json!({ + "processId": "proc-7", + "deltaBase64": null, + "closeStdin": true, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_terminate_round_trips() { + let params = CommandExecTerminateParams { + process_id: "proc-8".to_string(), + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec/terminate params"); + assert_eq!( + value, + json!({ + "processId": "proc-8", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_params_round_trip_with_size() { + let params = CommandExecParams { + command: vec!["top".to_string()], + process_id: Some("pty-1".to_string()), + tty: true, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + disable_output_cap: false, + disable_timeout: false, + timeout_ms: None, + cwd: None, + env: None, + size: Some(CommandExecTerminalSize { + rows: 40, + cols: 120, + }), + sandbox_policy: None, + permission_profile: None, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec params"); + assert_eq!( + value, + json!({ + "command": ["top"], + "processId": "pty-1", + "tty": true, + "outputBytesCap": null, + "timeoutMs": null, + "cwd": null, + "env": null, + "size": { + "rows": 40, + "cols": 120, + }, + "sandboxPolicy": null, + "permissionProfile": null, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_resize_round_trips() { + let params = CommandExecResizeParams { + process_id: "proc-9".to_string(), + size: CommandExecTerminalSize { + rows: 50, + cols: 160, + }, + }; + + let value = serde_json::to_value(¶ms).expect("serialize command/exec/resize params"); + assert_eq!( + value, + json!({ + "processId": "proc-9", + "size": { + "rows": 50, + "cols": 160, + }, + }) + ); + + let decoded = + serde_json::from_value::(value).expect("deserialize round-trip"); + assert_eq!(decoded, params); +} + +#[test] +fn command_exec_output_delta_round_trips() { + let notification = CommandExecOutputDeltaNotification { + process_id: "proc-1".to_string(), + stream: CommandExecOutputStream::Stdout, + delta_base64: "AQI=".to_string(), + cap_reached: false, + }; + + let value = serde_json::to_value(¬ification) + .expect("serialize command/exec/outputDelta notification"); + assert_eq!( + value, + json!({ + "processId": "proc-1", + "stream": "stdout", + "deltaBase64": "AQI=", + "capReached": false, + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize round-trip"); + assert_eq!(decoded, notification); +} + +#[test] +fn process_control_params_round_trip() { + let write = ProcessWriteStdinParams { + process_handle: "proc-7".to_string(), + delta_base64: None, + close_stdin: true, + }; + let value = serde_json::to_value(&write).expect("serialize process/writeStdin params"); + assert_eq!( + value, + json!({ + "processHandle": "proc-7", + "deltaBase64": null, + "closeStdin": true, + }) + ); + let decoded = serde_json::from_value::(value) + .expect("deserialize process/writeStdin params"); + assert_eq!(decoded, write); + + let resize = ProcessResizePtyParams { + process_handle: "proc-7".to_string(), + size: ProcessTerminalSize { + rows: 50, + cols: 160, + }, + }; + let value = serde_json::to_value(&resize).expect("serialize process/resizePty params"); + assert_eq!( + value, + json!({ + "processHandle": "proc-7", + "size": { + "rows": 50, + "cols": 160, + }, + }) + ); + let decoded = serde_json::from_value::(value) + .expect("deserialize process/resizePty params"); + assert_eq!(decoded, resize); + + let kill = ProcessKillParams { + process_handle: "proc-7".to_string(), + }; + let value = serde_json::to_value(&kill).expect("serialize process/kill params"); + assert_eq!( + value, + json!({ + "processHandle": "proc-7", + }) + ); + let decoded = + serde_json::from_value::(value).expect("deserialize process/kill"); + assert_eq!(decoded, kill); +} + +#[test] +fn process_notifications_round_trip() { + let delta = ProcessOutputDeltaNotification { + process_handle: "proc-1".to_string(), + stream: ProcessOutputStream::Stdout, + delta_base64: "AQI=".to_string(), + cap_reached: false, + }; + let value = serde_json::to_value(&delta).expect("serialize process/outputDelta"); + assert_eq!( + value, + json!({ + "processHandle": "proc-1", + "stream": "stdout", + "deltaBase64": "AQI=", + "capReached": false, + }) + ); + let decoded = serde_json::from_value::(value) + .expect("deserialize process/outputDelta"); + assert_eq!(decoded, delta); + + let exited = ProcessExitedNotification { + process_handle: "proc-1".to_string(), + exit_code: 0, + stdout: "out".to_string(), + stdout_cap_reached: false, + stderr: "err".to_string(), + stderr_cap_reached: true, + }; + let value = serde_json::to_value(&exited).expect("serialize process/exited"); + assert_eq!( + value, + json!({ + "processHandle": "proc-1", + "exitCode": 0, + "stdout": "out", + "stdoutCapReached": false, + "stderr": "err", + "stderrCapReached": true, + }) + ); + let decoded = serde_json::from_value::(value) + .expect("deserialize process/exited"); + assert_eq!(decoded, exited); +} + +#[test] +fn command_execution_output_delta_round_trips() { + let notification = CommandExecutionOutputDeltaNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + delta: "\u{fffd}a\n".to_string(), + }; + + let value = serde_json::to_value(¬ification) + .expect("serialize item/commandExecution/outputDelta notification"); + assert_eq!( + value, + json!({ + "threadId": "thread-1", + "turnId": "turn-1", + "itemId": "item-1", + "delta": "\u{fffd}a\n", + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize round-trip"); + assert_eq!(decoded, notification); +} + +#[test] +fn sandbox_policy_round_trips_external_sandbox_network_access() { + let v2_policy = SandboxPolicy::ExternalSandbox { + network_access: NetworkAccess::Enabled, + }; + + let core_policy = v2_policy.to_core(); + assert_eq!( + core_policy, + codex_protocol::protocol::SandboxPolicy::ExternalSandbox { + network_access: CoreNetworkAccess::Enabled, + } + ); + + let back_to_v2 = SandboxPolicy::from(core_policy); + assert_eq!(back_to_v2, v2_policy); +} + +#[test] +fn sandbox_policy_round_trips_read_only_network_access() { + let v2_policy = SandboxPolicy::ReadOnly { + network_access: true, + }; + + let core_policy = v2_policy.to_core(); + assert_eq!( + core_policy, + codex_protocol::protocol::SandboxPolicy::ReadOnly { + network_access: true, + } + ); + + let back_to_v2 = SandboxPolicy::from(core_policy); + assert_eq!(back_to_v2, v2_policy); +} + +#[test] +fn ask_for_approval_granular_round_trips_request_permissions_flag() { + let v2_policy = AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: false, + }; + + let core_policy = v2_policy.to_core(); + assert_eq!( + core_policy, + CoreAskForApproval::Granular(CoreGranularApprovalConfig { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: false, + }) + ); + + let back_to_v2 = AskForApproval::from(core_policy); + assert_eq!(back_to_v2, v2_policy); +} + +#[test] +fn ask_for_approval_granular_defaults_missing_optional_flags_to_false() { + let decoded = serde_json::from_value::(serde_json::json!({ + "granular": { + "sandbox_approval": true, + "rules": false, + "mcp_elicitations": true, + } + })) + .expect("granular approval policy should deserialize"); + + assert_eq!( + decoded, + AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + } + ); +} + +#[test] +fn ask_for_approval_granular_is_marked_experimental() { + let reason = + crate::experimental_api::ExperimentalApi::experimental_reason(&AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }); + + assert_eq!(reason, Some("askForApproval.granular")); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&AskForApproval::OnRequest,), + None + ); +} + +#[test] +fn profile_v2_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&ProfileV2 { + model: None, + model_provider: None, + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: false, + }), + approvals_reviewer: None, + service_tier: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + web_search: None, + tools: None, + chatgpt_base_url: None, + additional: HashMap::new(), + }); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn config_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { + model: None, + review_model: None, + model_context_window: None, + model_auto_compact_token_limit: None, + model_provider: None, + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: false, + rules: true, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + approvals_reviewer: None, + sandbox_mode: None, + sandbox_workspace_write: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, + web_search: None, + tools: None, + profile: None, + profiles: HashMap::new(), + instructions: None, + developer_instructions: None, + compact_prompt: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + service_tier: None, + analytics: None, + apps: None, + additional: HashMap::new(), + }); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn config_approvals_reviewer_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { + model: None, + review_model: None, + model_context_window: None, + model_auto_compact_token_limit: None, + model_provider: None, + approval_policy: None, + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), + sandbox_mode: None, + sandbox_workspace_write: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, + web_search: None, + tools: None, + profile: None, + profiles: HashMap::new(), + instructions: None, + developer_instructions: None, + compact_prompt: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + service_tier: None, + analytics: None, + apps: None, + additional: HashMap::new(), + }); + + assert_eq!(reason, Some("config/read.approvalsReviewer")); +} + +#[test] +fn config_nested_profile_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { + model: None, + review_model: None, + model_context_window: None, + model_auto_compact_token_limit: None, + model_provider: None, + approval_policy: None, + approvals_reviewer: None, + sandbox_mode: None, + sandbox_workspace_write: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, + web_search: None, + tools: None, + profile: None, + profiles: HashMap::from([( + "default".to_string(), + ProfileV2 { + model: None, + model_provider: None, + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + approvals_reviewer: None, + service_tier: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + web_search: None, + tools: None, + chatgpt_base_url: None, + additional: HashMap::new(), + }, + )]), + instructions: None, + developer_instructions: None, + compact_prompt: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + service_tier: None, + analytics: None, + apps: None, + additional: HashMap::new(), + }); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn config_nested_profile_approvals_reviewer_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&Config { + model: None, + review_model: None, + model_context_window: None, + model_auto_compact_token_limit: None, + model_provider: None, + approval_policy: None, + approvals_reviewer: None, + sandbox_mode: None, + sandbox_workspace_write: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, + web_search: None, + tools: None, + profile: None, + profiles: HashMap::from([( + "default".to_string(), + ProfileV2 { + model: None, + model_provider: None, + approval_policy: None, + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), + service_tier: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + web_search: None, + tools: None, + chatgpt_base_url: None, + additional: HashMap::new(), + }, + )]), + instructions: None, + developer_instructions: None, + compact_prompt: None, + model_reasoning_effort: None, + model_reasoning_summary: None, + model_verbosity: None, + service_tier: None, + analytics: None, + apps: None, + additional: HashMap::new(), + }); + + assert_eq!(reason, Some("config/read.approvalsReviewer")); +} + +#[test] +fn config_requirements_granular_allowed_approval_policy_is_marked_experimental() { + let reason = + crate::experimental_api::ExperimentalApi::experimental_reason(&ConfigRequirements { + allowed_approval_policies: Some(vec![AskForApproval::Granular { + sandbox_approval: true, + rules: true, + skill_approval: false, + request_permissions: false, + mcp_elicitations: false, + }]), + allowed_approvals_reviewers: None, + allowed_sandbox_modes: None, + allowed_web_search_modes: None, + allow_managed_hooks_only: None, + feature_requirements: None, + hooks: None, + enforce_residency: None, + network: None, + }); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn client_request_thread_start_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason( + &crate::ClientRequest::ThreadStart { + request_id: crate::RequestId::Integer(1), + params: ThreadStartParams { + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: true, + mcp_elicitations: false, + }), + ..Default::default() + }, + }, + ); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn client_request_thread_resume_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason( + &crate::ClientRequest::ThreadResume { + request_id: crate::RequestId::Integer(2), + params: ThreadResumeParams { + thread_id: "thr_123".to_string(), + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: false, + rules: true, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + ..Default::default() + }, + }, + ); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn client_request_thread_fork_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason( + &crate::ClientRequest::ThreadFork { + request_id: crate::RequestId::Integer(3), + params: ThreadForkParams { + thread_id: "thr_456".to_string(), + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: true, + rules: false, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + ..Default::default() + }, + }, + ); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn client_request_turn_start_granular_approval_policy_is_marked_experimental() { + let reason = crate::experimental_api::ExperimentalApi::experimental_reason( + &crate::ClientRequest::TurnStart { + request_id: crate::RequestId::Integer(4), + params: TurnStartParams { + thread_id: "thr_123".to_string(), + input: Vec::new(), + approval_policy: Some(AskForApproval::Granular { + sandbox_approval: false, + rules: true, + skill_approval: false, + request_permissions: false, + mcp_elicitations: true, + }), + ..Default::default() + }, + }, + ); + + assert_eq!(reason, Some("askForApproval.granular")); +} + +#[test] +fn mcp_server_elicitation_response_round_trips_rmcp_result() { + let rmcp_result = rmcp::model::CreateElicitationResult { + action: rmcp::model::ElicitationAction::Accept, + content: Some(json!({ + "confirmed": true, + })), + }; + + let v2_response = McpServerElicitationRequestResponse::from(rmcp_result.clone()); + assert_eq!( + v2_response, + McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Accept, + content: Some(json!({ + "confirmed": true, + })), + meta: None, + } + ); + assert_eq!( + rmcp::model::CreateElicitationResult::from(v2_response), + rmcp_result + ); +} + +#[test] +fn mcp_server_elicitation_request_from_core_url_request() { + let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::Url { + meta: None, + message: "Finish sign-in".to_string(), + url: "https://example.com/complete".to_string(), + elicitation_id: "elicitation-123".to_string(), + }) + .expect("URL request should convert"); + + assert_eq!( + request, + McpServerElicitationRequest::Url { + meta: None, + message: "Finish sign-in".to_string(), + url: "https://example.com/complete".to_string(), + elicitation_id: "elicitation-123".to_string(), + } + ); +} + +#[test] +fn mcp_server_elicitation_request_from_core_form_request() { + let request = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { + meta: None, + message: "Allow this request?".to_string(), + requested_schema: json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + } + }, + "required": ["confirmed"], + }), + }) + .expect("form request should convert"); + + let expected_schema: McpElicitationSchema = serde_json::from_value(json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "boolean", + } + }, + "required": ["confirmed"], + })) + .expect("expected schema should deserialize"); + + assert_eq!( + request, + McpServerElicitationRequest::Form { + meta: None, + message: "Allow this request?".to_string(), + requested_schema: expected_schema, + } + ); +} + +#[test] +fn mcp_elicitation_schema_matches_mcp_2025_11_25_primitives() { + let schema: McpElicitationSchema = serde_json::from_value(json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "email": { + "type": "string", + "title": "Email", + "description": "Work email address", + "format": "email", + "default": "dev@example.com", + }, + "count": { + "type": "integer", + "title": "Count", + "description": "How many items to create", + "minimum": 1, + "maximum": 5, + "default": 3, + }, + "confirmed": { + "type": "boolean", + "title": "Confirm", + "description": "Approve the pending action", + "default": true, + }, + "legacyChoice": { + "type": "string", + "title": "Action", + "description": "Legacy titled enum form", + "enum": ["allow", "deny"], + "enumNames": ["Allow", "Deny"], + "default": "allow", + }, + }, + "required": ["email", "confirmed"], + })) + .expect("schema should deserialize"); + + assert_eq!( + schema, + McpElicitationSchema { + schema_uri: Some("https://json-schema.org/draft/2020-12/schema".to_string()), + type_: McpElicitationObjectType::Object, + properties: BTreeMap::from([ + ( + "confirmed".to_string(), + McpElicitationPrimitiveSchema::Boolean(McpElicitationBooleanSchema { + type_: McpElicitationBooleanType::Boolean, + title: Some("Confirm".to_string()), + description: Some("Approve the pending action".to_string()), + default: Some(true), + }), + ), + ( + "count".to_string(), + McpElicitationPrimitiveSchema::Number(McpElicitationNumberSchema { + type_: McpElicitationNumberType::Integer, + title: Some("Count".to_string()), + description: Some("How many items to create".to_string()), + minimum: Some(1.0), + maximum: Some(5.0), + default: Some(3.0), + }), + ), + ( + "email".to_string(), + McpElicitationPrimitiveSchema::String(McpElicitationStringSchema { + type_: McpElicitationStringType::String, + title: Some("Email".to_string()), + description: Some("Work email address".to_string()), + min_length: None, + max_length: None, + format: Some(McpElicitationStringFormat::Email), + default: Some("dev@example.com".to_string()), + }), + ), + ( + "legacyChoice".to_string(), + McpElicitationPrimitiveSchema::Enum(McpElicitationEnumSchema::Legacy( + McpElicitationLegacyTitledEnumSchema { + type_: McpElicitationStringType::String, + title: Some("Action".to_string()), + description: Some("Legacy titled enum form".to_string()), + enum_: vec!["allow".to_string(), "deny".to_string()], + enum_names: Some(vec!["Allow".to_string(), "Deny".to_string(),]), + default: Some("allow".to_string()), + }, + )), + ), + ]), + required: Some(vec!["email".to_string(), "confirmed".to_string()]), + } + ); +} + +#[test] +fn mcp_server_elicitation_request_rejects_null_core_form_schema() { + let result = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { + meta: Some(json!({ + "persist": "session", + })), + message: "Allow this request?".to_string(), + requested_schema: JsonValue::Null, + }); + + assert!(result.is_err()); +} + +#[test] +fn mcp_server_elicitation_request_rejects_invalid_core_form_schema() { + let result = McpServerElicitationRequest::try_from(CoreElicitationRequest::Form { + meta: None, + message: "Allow this request?".to_string(), + requested_schema: json!({ + "type": "object", + "properties": { + "confirmed": { + "type": "object", + } + }, + }), + }); + + assert!(result.is_err()); +} + +#[test] +fn mcp_server_elicitation_response_serializes_nullable_content() { + let response = McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Decline, + content: None, + meta: None, + }; + + assert_eq!( + serde_json::to_value(response).expect("response should serialize"), + json!({ + "action": "decline", + "content": null, + "_meta": null, + }) + ); +} + +#[test] +fn sandbox_policy_round_trips_workspace_write_access() { + let v2_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + + let core_policy = v2_policy.to_core(); + assert_eq!( + core_policy, + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: true, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + } + ); + + let back_to_v2 = SandboxPolicy::from(core_policy); + assert_eq!(back_to_v2, v2_policy); +} + +#[test] +fn sandbox_policy_deserializes_legacy_read_only_full_access_field() { + let policy = serde_json::from_value::(json!({ + "type": "readOnly", + "access": { + "type": "fullAccess" + }, + "networkAccess": true + })) + .expect("read-only policy should ignore legacy fullAccess field"); + assert_eq!( + policy, + SandboxPolicy::ReadOnly { + network_access: true + } + ); +} + +#[test] +fn sandbox_policy_deserializes_legacy_workspace_write_full_access_field() { + let writable_root = absolute_path("/workspace"); + let policy = serde_json::from_value::(json!({ + "type": "workspaceWrite", + "writableRoots": [writable_root], + "readOnlyAccess": { + "type": "fullAccess" + }, + "networkAccess": true, + "excludeTmpdirEnvVar": true, + "excludeSlashTmp": true + })) + .expect("workspace-write policy should ignore legacy fullAccess field"); + assert_eq!( + policy, + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![absolute_path("/workspace")], + network_access: true, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + } + ); +} + +#[test] +fn sandbox_policy_rejects_legacy_read_only_restricted_access_field() { + let err = serde_json::from_value::(json!({ + "type": "readOnly", + "access": { + "type": "restricted", + "includePlatformDefaults": false, + "readableRoots": [] + } + })) + .expect_err("read-only policy should reject removed restricted access field"); + assert!(err.to_string().contains("readOnly.access")); +} + +#[test] +fn sandbox_policy_rejects_legacy_workspace_write_restricted_read_access_field() { + let err = serde_json::from_value::(json!({ + "type": "workspaceWrite", + "writableRoots": [], + "readOnlyAccess": { + "type": "restricted", + "includePlatformDefaults": false, + "readableRoots": [] + }, + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + })) + .expect_err("workspace-write policy should reject removed restricted readOnlyAccess field"); + assert!(err.to_string().contains("workspaceWrite.readOnlyAccess")); +} + +#[test] +fn automatic_approval_review_deserializes_aborted_status() { + let review: GuardianApprovalReview = serde_json::from_value(json!({ + "status": "aborted", + "riskLevel": null, + "userAuthorization": null, + "rationale": null + })) + .expect("aborted automatic review should deserialize"); + assert_eq!( + review, + GuardianApprovalReview { + status: GuardianApprovalReviewStatus::Aborted, + risk_level: None, + user_authorization: None, + rationale: None, + } + ); +} + +#[test] +fn guardian_approval_review_action_round_trips_command_shape() { + let value = json!({ + "type": "command", + "source": "shell", + "command": "rm -rf /tmp/example.sqlite", + "cwd": absolute_path_string("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: absolute_path("tmp"), + } + ); + assert_eq!( + serde_json::to_value(&action).expect("serialize guardian review action"), + value + ); +} + +#[test] +fn network_requirements_deserializes_legacy_fields() { + let requirements: NetworkRequirements = serde_json::from_value(json!({ + "allowedDomains": ["api.openai.com"], + "deniedDomains": ["blocked.example.com"], + "allowUnixSockets": ["/tmp/proxy.sock"] + })) + .expect("legacy network requirements should deserialize"); + + assert_eq!( + requirements, + NetworkRequirements { + enabled: None, + http_port: None, + socks_port: None, + allow_upstream_proxy: None, + dangerously_allow_non_loopback_proxy: None, + dangerously_allow_all_unix_sockets: None, + domains: None, + managed_allowed_domains_only: None, + allowed_domains: Some(vec!["api.openai.com".to_string()]), + denied_domains: Some(vec!["blocked.example.com".to_string()]), + unix_sockets: None, + allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), + allow_local_binding: None, + } + ); +} + +#[test] +fn network_requirements_serializes_canonical_and_legacy_fields() { + let requirements = NetworkRequirements { + enabled: Some(true), + http_port: Some(8080), + socks_port: Some(1080), + allow_upstream_proxy: Some(false), + dangerously_allow_non_loopback_proxy: Some(false), + dangerously_allow_all_unix_sockets: Some(true), + domains: Some(BTreeMap::from([ + ("api.openai.com".to_string(), NetworkDomainPermission::Allow), + ( + "blocked.example.com".to_string(), + NetworkDomainPermission::Deny, + ), + ])), + managed_allowed_domains_only: Some(true), + allowed_domains: Some(vec!["api.openai.com".to_string()]), + denied_domains: Some(vec!["blocked.example.com".to_string()]), + unix_sockets: Some(BTreeMap::from([ + ( + "/tmp/proxy.sock".to_string(), + NetworkUnixSocketPermission::Allow, + ), + ( + "/tmp/ignored.sock".to_string(), + NetworkUnixSocketPermission::None, + ), + ])), + allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), + allow_local_binding: Some(true), + }; + + assert_eq!( + serde_json::to_value(requirements).expect("network requirements should serialize"), + json!({ + "enabled": true, + "httpPort": 8080, + "socksPort": 1080, + "allowUpstreamProxy": false, + "dangerouslyAllowNonLoopbackProxy": false, + "dangerouslyAllowAllUnixSockets": true, + "domains": { + "api.openai.com": "allow", + "blocked.example.com": "deny" + }, + "managedAllowedDomainsOnly": true, + "allowedDomains": ["api.openai.com"], + "deniedDomains": ["blocked.example.com"], + "unixSockets": { + "/tmp/ignored.sock": "none", + "/tmp/proxy.sock": "allow" + }, + "allowUnixSockets": ["/tmp/proxy.sock"], + "allowLocalBinding": true + }) + ); +} + +#[test] +fn core_turn_item_into_thread_item_converts_supported_variants() { + let user_item = TurnItem::UserMessage(UserMessageItem { + id: "user-1".to_string(), + content: vec![ + CoreUserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }, + CoreUserInput::Image { + image_url: "https://example.com/image.png".to_string(), + }, + CoreUserInput::LocalImage { + path: PathBuf::from("local/image.png"), + }, + CoreUserInput::Skill { + name: "skill-creator".to_string(), + path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"), + }, + CoreUserInput::Mention { + name: "Demo App".to_string(), + path: "app://demo-app".to_string(), + }, + ], + }); + + assert_eq!( + ThreadItem::from(user_item), + ThreadItem::UserMessage { + id: "user-1".to_string(), + content: vec![ + UserInput::Text { + text: "hello".to_string(), + text_elements: Vec::new(), + }, + UserInput::Image { + url: "https://example.com/image.png".to_string(), + }, + UserInput::LocalImage { + path: PathBuf::from("local/image.png"), + }, + UserInput::Skill { + name: "skill-creator".to_string(), + path: PathBuf::from("/repo/.codex/skills/skill-creator/SKILL.md"), + }, + UserInput::Mention { + name: "Demo App".to_string(), + path: "app://demo-app".to_string(), + }, + ], + } + ); + + let agent_item = TurnItem::AgentMessage(AgentMessageItem { + id: "agent-1".to_string(), + content: vec![ + AgentMessageContent::Text { + text: "Hello ".to_string(), + }, + AgentMessageContent::Text { + text: "world".to_string(), + }, + ], + phase: None, + memory_citation: None, + }); + + assert_eq!( + ThreadItem::from(agent_item), + ThreadItem::AgentMessage { + id: "agent-1".to_string(), + text: "Hello world".to_string(), + phase: None, + memory_citation: None, + } + ); + + let agent_item_with_phase = TurnItem::AgentMessage(AgentMessageItem { + id: "agent-2".to_string(), + content: vec![AgentMessageContent::Text { + text: "final".to_string(), + }], + phase: Some(MessagePhase::FinalAnswer), + memory_citation: Some(CoreMemoryCitation { + entries: vec![CoreMemoryCitationEntry { + path: "MEMORY.md".to_string(), + line_start: 1, + line_end: 2, + note: "summary".to_string(), + }], + rollout_ids: vec!["rollout-1".to_string()], + }), + }); + + assert_eq!( + ThreadItem::from(agent_item_with_phase), + ThreadItem::AgentMessage { + id: "agent-2".to_string(), + text: "final".to_string(), + phase: Some(MessagePhase::FinalAnswer), + memory_citation: Some(MemoryCitation { + entries: vec![MemoryCitationEntry { + path: "MEMORY.md".to_string(), + line_start: 1, + line_end: 2, + note: "summary".to_string(), + }], + thread_ids: vec!["rollout-1".to_string()], + }), + } + ); + + let reasoning_item = TurnItem::Reasoning(ReasoningItem { + id: "reasoning-1".to_string(), + summary_text: vec!["line one".to_string(), "line two".to_string()], + raw_content: vec![], + }); + + assert_eq!( + ThreadItem::from(reasoning_item), + ThreadItem::Reasoning { + id: "reasoning-1".to_string(), + summary: vec!["line one".to_string(), "line two".to_string()], + content: vec![], + } + ); + + let search_item = TurnItem::WebSearch(WebSearchItem { + id: "search-1".to_string(), + query: "docs".to_string(), + action: CoreWebSearchAction::Search { + query: Some("docs".to_string()), + queries: None, + }, + }); + + assert_eq!( + ThreadItem::from(search_item), + ThreadItem::WebSearch { + id: "search-1".to_string(), + query: "docs".to_string(), + action: Some(WebSearchAction::Search { + query: Some("docs".to_string()), + queries: None, + }), + } + ); + + let image_view_item = TurnItem::ImageView(ImageViewItem { + id: "view-image-1".to_string(), + path: test_path_buf("/tmp/view-image.png").abs(), + }); + + assert_eq!( + ThreadItem::from(image_view_item), + ThreadItem::ImageView { + id: "view-image-1".to_string(), + path: test_path_buf("/tmp/view-image.png").abs(), + } + ); + + let file_change_item = TurnItem::FileChange(FileChangeItem { + id: "patch-1".to_string(), + changes: [( + PathBuf::from("README.md"), + codex_protocol::protocol::FileChange::Add { + content: "hello\n".to_string(), + }, + )] + .into_iter() + .collect(), + status: Some(codex_protocol::protocol::PatchApplyStatus::Completed), + auto_approved: None, + stdout: Some("Done!".to_string()), + stderr: Some(String::new()), + }); + + assert_eq!( + ThreadItem::from(file_change_item), + ThreadItem::FileChange { + id: "patch-1".to_string(), + changes: vec![FileUpdateChange { + path: "README.md".to_string(), + kind: PatchChangeKind::Add, + diff: "hello\n".to_string(), + }], + status: PatchApplyStatus::Completed, + } + ); + + let mcp_tool_call_item = TurnItem::McpToolCall(McpToolCallItem { + id: "mcp-1".to_string(), + server: "server".to_string(), + tool: "tool".to_string(), + arguments: json!({"arg": "value"}), + mcp_app_resource_uri: Some("app://connector".to_string()), + status: CoreMcpToolCallStatus::InProgress, + result: None, + error: None, + duration: None, + }); + + assert_eq!( + ThreadItem::from(mcp_tool_call_item), + ThreadItem::McpToolCall { + id: "mcp-1".to_string(), + server: "server".to_string(), + tool: "tool".to_string(), + status: McpToolCallStatus::InProgress, + arguments: json!({"arg": "value"}), + mcp_app_resource_uri: Some("app://connector".to_string()), + result: None, + error: None, + duration_ms: None, + } + ); + + let completed_mcp_tool_call_item = TurnItem::McpToolCall(McpToolCallItem { + id: "mcp-2".to_string(), + server: "server".to_string(), + tool: "tool".to_string(), + arguments: JsonValue::Null, + mcp_app_resource_uri: None, + status: CoreMcpToolCallStatus::Completed, + result: Some(CallToolResult { + content: vec![json!({"type": "text", "text": "ok"})], + structured_content: Some(json!({"ok": true})), + is_error: Some(false), + meta: Some(json!({"trace": "1"})), + }), + error: None, + duration: Some(Duration::from_millis(42)), + }); + + assert_eq!( + ThreadItem::from(completed_mcp_tool_call_item), + ThreadItem::McpToolCall { + id: "mcp-2".to_string(), + server: "server".to_string(), + tool: "tool".to_string(), + status: McpToolCallStatus::Completed, + arguments: JsonValue::Null, + mcp_app_resource_uri: None, + result: Some(Box::new(McpToolCallResult { + content: vec![json!({"type": "text", "text": "ok"})], + structured_content: Some(json!({"ok": true})), + meta: Some(json!({"trace": "1"})), + })), + error: None, + duration_ms: Some(42), + } + ); +} + +#[test] +fn skills_list_params_serialization_uses_force_reload() { + assert_eq!( + serde_json::to_value(SkillsListParams { + cwds: Vec::new(), + force_reload: false, + }) + .unwrap(), + json!({}), + ); + + assert_eq!( + serde_json::to_value(SkillsListParams { + cwds: vec![PathBuf::from("/repo")], + force_reload: true, + }) + .unwrap(), + json!({ + "cwds": ["/repo"], + "forceReload": true, + }), + ); +} + +#[test] +fn plugin_source_serializes_local_git_and_remote_variants() { + let local_path = if cfg!(windows) { + r"C:\plugins\linear" + } else { + "/plugins/linear" + }; + let local_path = AbsolutePathBuf::try_from(PathBuf::from(local_path)).unwrap(); + let local_path_json = local_path.as_path().display().to_string(); + + assert_eq!( + serde_json::to_value(PluginSource::Local { path: local_path }).unwrap(), + json!({ + "type": "local", + "path": local_path_json, + }), + ); + + assert_eq!( + serde_json::to_value(PluginSource::Git { + url: "https://github.com/openai/example.git".to_string(), + path: Some("plugins/example".to_string()), + ref_name: Some("main".to_string()), + sha: Some("abc123".to_string()), + }) + .unwrap(), + json!({ + "type": "git", + "url": "https://github.com/openai/example.git", + "path": "plugins/example", + "refName": "main", + "sha": "abc123", + }), + ); + + assert_eq!( + serde_json::to_value(PluginSource::Remote).unwrap(), + json!({ + "type": "remote", + }), + ); +} + +#[test] +fn marketplace_add_params_serialization_uses_optional_ref_name_and_sparse_paths() { + assert_eq!( + serde_json::to_value(MarketplaceAddParams { + source: "owner/repo".to_string(), + ref_name: None, + sparse_paths: None, + }) + .unwrap(), + json!({ + "source": "owner/repo", + "refName": null, + "sparsePaths": null, + }), + ); + + assert_eq!( + serde_json::to_value(MarketplaceAddParams { + source: "owner/repo".to_string(), + ref_name: Some("main".to_string()), + sparse_paths: Some(vec!["plugins/foo".to_string()]), + }) + .unwrap(), + json!({ + "source": "owner/repo", + "refName": "main", + "sparsePaths": ["plugins/foo"], + }), + ); +} + +#[test] +fn marketplace_upgrade_params_serialization_uses_optional_marketplace_name() { + assert_eq!( + serde_json::to_value(MarketplaceUpgradeParams { + marketplace_name: None, + }) + .unwrap(), + json!({ + "marketplaceName": null, + }), + ); + + assert_eq!( + serde_json::from_value::(json!({})).unwrap(), + MarketplaceUpgradeParams { + marketplace_name: None, + }, + ); + + assert_eq!( + serde_json::to_value(MarketplaceUpgradeParams { + marketplace_name: Some("debug".to_string()), + }) + .unwrap(), + json!({ + "marketplaceName": "debug", + }), + ); +} + +#[test] +fn plugin_marketplace_entry_serializes_remote_only_path_as_null() { + assert_eq!( + serde_json::to_value(PluginMarketplaceEntry { + name: "openai-curated".to_string(), + path: None, + interface: None, + plugins: Vec::new(), + }) + .unwrap(), + json!({ + "name": "openai-curated", + "path": null, + "interface": null, + "plugins": [], + }), + ); +} + +#[test] +fn plugin_interface_serializes_local_paths_and_remote_urls_separately() { + let composer_icon = if cfg!(windows) { + r"C:\plugins\linear\icon.png" + } else { + "/plugins/linear/icon.png" + }; + let composer_icon = AbsolutePathBuf::try_from(PathBuf::from(composer_icon)).unwrap(); + let composer_icon_json = composer_icon.as_path().display().to_string(); + + let interface = PluginInterface { + display_name: Some("Linear".to_string()), + short_description: None, + long_description: None, + developer_name: None, + category: Some("Productivity".to_string()), + capabilities: Vec::new(), + website_url: None, + privacy_policy_url: None, + terms_of_service_url: None, + default_prompt: None, + brand_color: None, + composer_icon: Some(composer_icon), + composer_icon_url: Some("https://example.com/linear/icon.png".to_string()), + logo: None, + logo_url: Some("https://example.com/linear/logo.png".to_string()), + screenshots: Vec::new(), + screenshot_urls: vec!["https://example.com/linear/screenshot.png".to_string()], + }; + + assert_eq!( + serde_json::to_value(interface).unwrap(), + json!({ + "displayName": "Linear", + "shortDescription": null, + "longDescription": null, + "developerName": null, + "category": "Productivity", + "capabilities": [], + "websiteUrl": null, + "privacyPolicyUrl": null, + "termsOfServiceUrl": null, + "defaultPrompt": null, + "brandColor": null, + "composerIcon": composer_icon_json, + "composerIconUrl": "https://example.com/linear/icon.png", + "logo": null, + "logoUrl": "https://example.com/linear/logo.png", + "screenshots": [], + "screenshotUrls": ["https://example.com/linear/screenshot.png"], + }), + ); +} + +#[test] +fn plugin_list_params_ignore_removed_force_remote_sync_field() { + assert_eq!( + serde_json::from_value::(json!({ + "cwds": null, + "forceRemoteSync": true, + })) + .unwrap(), + PluginListParams { + cwds: None, + marketplace_kinds: None, + }, + ); +} + +#[test] +fn plugin_list_params_serializes_marketplace_kind_filter() { + assert_eq!( + serde_json::to_value(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![ + PluginListMarketplaceKind::Local, + PluginListMarketplaceKind::WorkspaceDirectory, + PluginListMarketplaceKind::SharedWithMe, + ]), + }) + .unwrap(), + json!({ + "cwds": null, + "marketplaceKinds": [ + "local", + "workspace-directory", + "shared-with-me", + ], + }), + ); +} + +#[test] +fn plugin_read_params_serialization_uses_install_source_fields() { + let marketplace_path = if cfg!(windows) { + r"C:\plugins\marketplace.json" + } else { + "/plugins/marketplace.json" + }; + let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap(); + let marketplace_path_json = marketplace_path.as_path().display().to_string(); + assert_eq!( + serde_json::to_value(PluginReadParams { + marketplace_path: Some(marketplace_path.clone()), + remote_marketplace_name: None, + plugin_name: "gmail".to_string(), + }) + .unwrap(), + json!({ + "marketplacePath": marketplace_path_json, + "remoteMarketplaceName": null, + "pluginName": "gmail", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({ + "marketplacePath": marketplace_path_json, + "pluginName": "gmail", + "forceRemoteSync": true, + })) + .unwrap(), + PluginReadParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "gmail".to_string(), + }, + ); + + assert_eq!( + serde_json::from_value::(json!({ + "remoteMarketplaceName": "openai-curated", + "pluginName": "gmail", + })) + .unwrap(), + PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some("openai-curated".to_string()), + plugin_name: "gmail".to_string(), + }, + ); +} + +#[test] +fn plugin_install_params_serialization_omits_force_remote_sync() { + let marketplace_path = if cfg!(windows) { + r"C:\plugins\marketplace.json" + } else { + "/plugins/marketplace.json" + }; + let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap(); + let marketplace_path_json = marketplace_path.as_path().display().to_string(); + assert_eq!( + serde_json::to_value(PluginInstallParams { + marketplace_path: Some(marketplace_path.clone()), + remote_marketplace_name: None, + plugin_name: "gmail".to_string(), + }) + .unwrap(), + json!({ + "marketplacePath": marketplace_path_json, + "remoteMarketplaceName": null, + "pluginName": "gmail", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({ + "marketplacePath": marketplace_path_json, + "pluginName": "gmail", + "forceRemoteSync": true, + })) + .unwrap(), + PluginInstallParams { + marketplace_path: Some(marketplace_path), + remote_marketplace_name: None, + plugin_name: "gmail".to_string(), + }, + ); + + assert_eq!( + serde_json::from_value::(json!({ + "remoteMarketplaceName": "openai-curated", + "pluginName": "gmail", + "forceRemoteSync": true, + })) + .unwrap(), + PluginInstallParams { + marketplace_path: None, + remote_marketplace_name: Some("openai-curated".to_string()), + plugin_name: "gmail".to_string(), + }, + ); +} + +#[test] +fn plugin_skill_read_params_serialization_uses_remote_plugin_id() { + assert_eq!( + serde_json::to_value(PluginSkillReadParams { + remote_marketplace_name: "chatgpt-global".to_string(), + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + skill_name: "plan-work".to_string(), + }) + .unwrap(), + json!({ + "remoteMarketplaceName": "chatgpt-global", + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "skillName": "plan-work", + }), + ); +} + +#[test] +fn plugin_share_params_and_response_serialization_use_camel_case_fields() { + let plugin_path = if cfg!(windows) { + r"C:\plugins\gmail" + } else { + "/plugins/gmail" + }; + let plugin_path = AbsolutePathBuf::try_from(PathBuf::from(plugin_path)).unwrap(); + let plugin_path_json = plugin_path.as_path().display().to_string(); + + assert_eq!( + serde_json::to_value(PluginShareSaveParams { + plugin_path: plugin_path.clone(), + remote_plugin_id: None, + discoverability: None, + share_targets: None, + }) + .unwrap(), + json!({ + "pluginPath": plugin_path_json, + "remotePluginId": null, + "discoverability": null, + "shareTargets": null, + }), + ); + + assert_eq!( + serde_json::to_value(PluginShareSaveParams { + plugin_path, + remote_plugin_id: Some("plugins~Plugin_00000000000000000000000000000000".to_string(),), + discoverability: Some(PluginShareDiscoverability::Private), + share_targets: Some(vec![ + PluginShareTarget { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + role: PluginShareTargetRole::Reader, + }, + PluginShareTarget { + principal_type: PluginSharePrincipalType::Group, + principal_id: "group-1".to_string(), + role: PluginShareTargetRole::Reader, + }, + ]), + }) + .unwrap(), + json!({ + "pluginPath": plugin_path_json, + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "discoverability": "PRIVATE", + "shareTargets": [ + { + "principalType": "user", + "principalId": "user-1", + "role": "reader", + }, + { + "principalType": "group", + "principalId": "group-1", + "role": "reader", + }, + ], + }), + ); + + assert_eq!( + serde_json::to_value(PluginShareSaveResponse { + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + share_url: String::new(), + }) + .unwrap(), + json!({ + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "shareUrl": "", + }), + ); + + assert_eq!( + serde_json::to_value(PluginShareUpdateTargetsParams { + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + discoverability: PluginShareUpdateDiscoverability::Unlisted, + share_targets: vec![PluginShareTarget { + principal_type: PluginSharePrincipalType::Group, + principal_id: "group-1".to_string(), + role: PluginShareTargetRole::Editor, + }], + }) + .unwrap(), + json!({ + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "discoverability": "UNLISTED", + "shareTargets": [{ + "principalType": "group", + "principalId": "group-1", + "role": "editor", + }], + }), + ); + + assert_eq!( + serde_json::to_value(PluginShareUpdateTargetsResponse { + principals: vec![PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + role: PluginSharePrincipalRole::Owner, + name: "Gavin".to_string(), + }], + discoverability: PluginShareDiscoverability::Unlisted, + }) + .unwrap(), + json!({ + "principals": [{ + "principalType": "user", + "principalId": "user-1", + "role": "owner", + "name": "Gavin", + }], + "discoverability": "UNLISTED", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({})).unwrap(), + PluginShareListParams {}, + ); + + assert_eq!( + serde_json::to_value(PluginShareDeleteParams { + remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + }) + .unwrap(), + json!({ + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + }), + ); +} + +#[test] +fn plugin_share_list_response_serializes_share_items() { + assert_eq!( + serde_json::to_value(PluginShareListResponse { + data: vec![PluginShareListItem { + plugin: PluginSummary { + id: "gmail@chatgpt-global".to_string(), + remote_plugin_id: Some( + "plugins~Plugin_00000000000000000000000000000000".to_string(), + ), + local_version: None, + name: "gmail".to_string(), + share_context: None, + source: PluginSource::Remote, + installed: false, + enabled: false, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnUse, + availability: PluginAvailability::Available, + interface: None, + keywords: Vec::new(), + }, + local_plugin_path: None, + }], + }) + .unwrap(), + json!({ + "data": [{ + "plugin": { + "id": "gmail@chatgpt-global", + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "localVersion": null, + "name": "gmail", + "shareContext": null, + "source": { "type": "remote" }, + "installed": false, + "enabled": false, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_USE", + "availability": "AVAILABLE", + "interface": null, + "keywords": [], + }, + "localPluginPath": null, + }], + }), + ); +} + +#[test] +fn plugin_summary_defaults_missing_availability_to_available() { + let summary: PluginSummary = serde_json::from_value(json!({ + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "gmail", + "source": { "type": "remote" }, + "installed": false, + "enabled": false, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_USE", + "interface": null, + })) + .unwrap(); + + assert_eq!(summary.availability, PluginAvailability::Available); + assert_eq!(summary.local_version, None); + assert_eq!(summary.share_context, None); +} + +#[test] +fn plugin_availability_deserializes_enabled_alias() { + let availability: PluginAvailability = serde_json::from_value(json!("ENABLED")).unwrap(); + + assert_eq!(availability, PluginAvailability::Available); + assert_eq!( + serde_json::to_value(availability).unwrap(), + json!("AVAILABLE") + ); +} + +#[test] +fn plugin_uninstall_params_serialization_omits_force_remote_sync() { + assert_eq!( + serde_json::to_value(PluginUninstallParams { + plugin_id: "gmail@openai-curated".to_string(), + }) + .unwrap(), + json!({ + "pluginId": "gmail@openai-curated", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({ + "pluginId": "gmail@openai-curated", + "forceRemoteSync": true, + })) + .unwrap(), + PluginUninstallParams { + plugin_id: "gmail@openai-curated".to_string(), + }, + ); + + assert_eq!( + serde_json::to_value(PluginUninstallParams { + plugin_id: "plugins~Plugin_gmail".to_string(), + }) + .unwrap(), + json!({ + "pluginId": "plugins~Plugin_gmail", + }), + ); + + assert_eq!( + serde_json::from_value::(json!({ + "pluginId": "plugins~Plugin_gmail", + "forceRemoteSync": true, + })) + .unwrap(), + PluginUninstallParams { + plugin_id: "plugins~Plugin_gmail".to_string(), + }, + ); +} + +#[test] +fn marketplace_remove_response_serializes_nullable_installed_root() { + let installed_root = if cfg!(windows) { + r"C:\marketplaces\debug" + } else { + "/tmp/marketplaces/debug" + }; + let installed_root = AbsolutePathBuf::try_from(PathBuf::from(installed_root)).unwrap(); + let installed_root_json = installed_root.as_path().display().to_string(); + assert_eq!( + serde_json::to_value(MarketplaceRemoveResponse { + marketplace_name: "debug".to_string(), + installed_root: Some(installed_root), + }) + .unwrap(), + json!({ + "marketplaceName": "debug", + "installedRoot": installed_root_json, + }), + ); + + assert_eq!( + serde_json::to_value(MarketplaceRemoveResponse { + marketplace_name: "debug".to_string(), + installed_root: None, + }) + .unwrap(), + json!({ + "marketplaceName": "debug", + "installedRoot": null, + }), + ); +} + +#[test] +fn marketplace_upgrade_response_serializes_camel_case_fields() { + let upgraded_root = if cfg!(windows) { + r"C:\marketplaces\debug" + } else { + "/tmp/marketplaces/debug" + }; + let upgraded_root = AbsolutePathBuf::try_from(PathBuf::from(upgraded_root)).unwrap(); + let upgraded_root_json = upgraded_root.as_path().display().to_string(); + + assert_eq!( + serde_json::to_value(MarketplaceUpgradeResponse { + selected_marketplaces: vec!["debug".to_string()], + upgraded_roots: vec![upgraded_root], + errors: vec![MarketplaceUpgradeErrorInfo { + marketplace_name: "broken".to_string(), + message: "failed to clone".to_string(), + }], + }) + .unwrap(), + json!({ + "selectedMarketplaces": ["debug"], + "upgradedRoots": [upgraded_root_json], + "errors": [{ + "marketplaceName": "broken", + "message": "failed to clone", + }], + }), + ); +} + +#[test] +fn codex_error_info_serializes_http_status_code_in_camel_case() { + let value = CodexErrorInfo::ResponseTooManyFailedAttempts { + http_status_code: Some(401), + }; + + assert_eq!( + serde_json::to_value(value).unwrap(), + json!({ + "responseTooManyFailedAttempts": { + "httpStatusCode": 401 + } + }) + ); +} + +#[test] +fn codex_error_info_serializes_cyber_policy_in_camel_case() { + assert_eq!( + serde_json::to_value(CodexErrorInfo::CyberPolicy).unwrap(), + json!("cyberPolicy") + ); +} + +#[test] +fn codex_error_info_serializes_active_turn_not_steerable_turn_kind_in_camel_case() { + let value = CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: NonSteerableTurnKind::Review, + }; + + assert_eq!( + serde_json::to_value(value).unwrap(), + json!({ + "activeTurnNotSteerable": { + "turnKind": "review" + } + }) + ); +} + +#[test] +fn dynamic_tool_response_serializes_content_items() { + let value = serde_json::to_value(DynamicToolCallResponse { + content_items: vec![DynamicToolCallOutputContentItem::InputText { + text: "dynamic-ok".to_string(), + }], + success: true, + }) + .unwrap(); + + assert_eq!( + value, + json!({ + "contentItems": [ + { + "type": "inputText", + "text": "dynamic-ok" + } + ], + "success": true, + }) + ); +} + +#[test] +fn dynamic_tool_response_serializes_text_and_image_content_items() { + let value = serde_json::to_value(DynamicToolCallResponse { + content_items: vec![ + DynamicToolCallOutputContentItem::InputText { + text: "dynamic-ok".to_string(), + }, + DynamicToolCallOutputContentItem::InputImage { + image_url: "data:image/png;base64,AAA".to_string(), + }, + ], + success: true, + }) + .unwrap(); + + assert_eq!( + value, + json!({ + "contentItems": [ + { + "type": "inputText", + "text": "dynamic-ok" + }, + { + "type": "inputImage", + "imageUrl": "data:image/png;base64,AAA" + } + ], + "success": true, + }) + ); +} + +#[test] +fn dynamic_tool_spec_deserializes_defer_loading() { + let value = json!({ + "name": "lookup_ticket", + "description": "Fetch a ticket", + "inputSchema": { + "type": "object", + "properties": { + "id": { "type": "string" } + } + }, + "deferLoading": true, + }); + + let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize"); + + assert_eq!( + actual, + DynamicToolSpec { + namespace: None, + name: "lookup_ticket".to_string(), + description: "Fetch a ticket".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "id": { "type": "string" } + } + }), + defer_loading: true, + } + ); +} + +#[test] +fn dynamic_tool_spec_legacy_expose_to_context_inverts_to_defer_loading() { + let value = json!({ + "name": "lookup_ticket", + "description": "Fetch a ticket", + "inputSchema": { + "type": "object", + "properties": {} + }, + "exposeToContext": false, + }); + + let actual: DynamicToolSpec = serde_json::from_value(value).expect("deserialize"); + + assert!(actual.defer_loading); +} + +#[test] +fn thread_start_params_preserve_explicit_null_service_tier() { + let params: ThreadStartParams = + serde_json::from_value(json!({ "serviceTier": null })).expect("params should deserialize"); + assert_eq!(params.service_tier, Some(None)); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("serviceTier"), + Some(&serde_json::Value::Null) + ); + + let serialized_without_override = + serde_json::to_value(ThreadStartParams::default()).expect("params should serialize"); + assert_eq!(serialized_without_override.get("serviceTier"), None); +} + +#[test] +fn thread_lifecycle_responses_default_missing_optional_fields() { + let response = json!({ + "thread": { + "id": "thread-id", + "sessionId": "thread-id", + "forkedFromId": null, + "preview": "", + "ephemeral": false, + "modelProvider": "openai", + "createdAt": 1, + "updatedAt": 1, + "status": { "type": "idle" }, + "path": null, + "cwd": absolute_path_string("tmp"), + "cliVersion": "0.0.0", + "source": "exec", + "agentNickname": null, + "agentRole": null, + "gitInfo": null, + "name": null, + "turns": [] + }, + "model": "gpt-5", + "modelProvider": "openai", + "serviceTier": null, + "cwd": absolute_path_string("tmp"), + "approvalPolicy": "on-failure", + "approvalsReviewer": "user", + "sandbox": { "type": "dangerFullAccess" }, + "reasoningEffort": null + }); + + let start: ThreadStartResponse = + serde_json::from_value(response.clone()).expect("thread/start response"); + let resume: ThreadResumeResponse = + serde_json::from_value(response.clone()).expect("thread/resume response"); + let fork: ThreadForkResponse = serde_json::from_value(response).expect("thread/fork response"); + + assert_eq!(start.instruction_sources, Vec::::new()); + assert_eq!(resume.instruction_sources, Vec::::new()); + assert_eq!(fork.instruction_sources, Vec::::new()); + assert_eq!(start.permission_profile, None); + assert_eq!(resume.permission_profile, None); + assert_eq!(fork.permission_profile, None); + assert_eq!(start.active_permission_profile, None); + assert_eq!(resume.active_permission_profile, None); + assert_eq!(fork.active_permission_profile, None); +} + +#[test] +fn turn_start_params_preserve_explicit_null_service_tier() { + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "serviceTier": null + })) + .expect("params should deserialize"); + assert_eq!(params.service_tier, Some(None)); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("serviceTier"), + Some(&serde_json::Value::Null) + ); + + let without_override = TurnStartParams { + thread_id: "thread_123".to_string(), + input: vec![], + responsesapi_client_metadata: None, + environments: None, + cwd: None, + approval_policy: None, + approvals_reviewer: None, + sandbox_policy: None, + permissions: None, + model: None, + service_tier: None, + effort: None, + summary: None, + output_schema: None, + collaboration_mode: None, + personality: None, + }; + let serialized_without_override = + serde_json::to_value(&without_override).expect("params should serialize"); + assert_eq!(serialized_without_override.get("serviceTier"), None); +} + +#[test] +fn turn_start_params_round_trip_environments() { + let cwd = test_absolute_path(); + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "environments": [ + { + "environmentId": "local", + "cwd": cwd + } + ], + })) + .expect("params should deserialize"); + + assert_eq!( + params.environments, + Some(vec![TurnEnvironmentParams { + environment_id: "local".to_string(), + cwd: cwd.clone(), + }]) + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), + Some("turn/start.environments") + ); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!( + serialized.get("environments"), + Some(&json!([ + { + "environmentId": "local", + "cwd": cwd + } + ])) + ); +} + +#[test] +fn turn_start_params_preserve_empty_environments() { + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "environments": [], + })) + .expect("params should deserialize"); + + assert_eq!(params.environments, Some(Vec::new())); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(¶ms), + Some("turn/start.environments") + ); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!(serialized.get("environments"), Some(&json!([]))); +} + +#[test] +fn turn_start_params_treat_null_or_omitted_environments_as_default() { + let null_environments: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + "environments": null, + })) + .expect("params should deserialize"); + let omitted_environments: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread_123", + "input": [], + })) + .expect("params should deserialize"); + + assert_eq!(null_environments.environments, None); + assert_eq!(omitted_environments.environments, None); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&null_environments), + None + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&omitted_environments), + None + ); +} + +#[test] +fn turn_start_params_reject_relative_environment_cwd() { + let err = serde_json::from_value::(json!({ + "threadId": "thread_123", + "input": [], + "environments": [ + { + "environmentId": "local", + "cwd": "relative" + } + ], + })) + .expect_err("relative environment cwd should fail"); + + assert!( + err.to_string() + .contains("AbsolutePathBuf deserialized without a base path"), + "unexpected error: {err}" + ); +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs new file mode 100644 index 0000000000..458722b3a2 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -0,0 +1,1187 @@ +use super::ActivePermissionProfile; +use super::ApprovalsReviewer; +use super::AskForApproval; +use super::PermissionProfile; +use super::PermissionProfileSelectionParams; +use super::SandboxMode; +use super::SandboxPolicy; +use super::Thread; +use super::ThreadItem; +use super::ThreadSource; +use super::Turn; +use super::TurnEnvironmentParams; +use super::TurnItemsView; +use super::shared::v2_enum_from_core; +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::config_types::Personality; +use codex_protocol::models::ResponseItem; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus; +use codex_protocol::protocol::TokenUsage as CoreTokenUsage; +use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use std::collections::HashMap; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum ThreadStartSource { + Startup, + Clear, +} + +#[derive(Serialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct DynamicToolSpec { + #[ts(optional)] + pub namespace: Option, + pub name: String, + pub description: String, + pub input_schema: JsonValue, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub defer_loading: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct DynamicToolSpecDe { + namespace: Option, + name: String, + description: String, + input_schema: JsonValue, + defer_loading: Option, + expose_to_context: Option, +} + +impl<'de> Deserialize<'de> for DynamicToolSpec { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let DynamicToolSpecDe { + namespace, + name, + description, + input_schema, + defer_loading, + expose_to_context, + } = DynamicToolSpecDe::deserialize(deserializer)?; + + Ok(Self { + namespace, + name, + description, + input_schema, + defer_loading: defer_loading + .unwrap_or_else(|| expose_to_context.map(|visible| !visible).unwrap_or(false)), + }) + } +} + +// === Threads, Turns, and Items === +// Thread APIs +#[derive( + Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStartParams { + #[ts(optional = nullable)] + pub model: Option, + #[ts(optional = nullable)] + pub model_provider: Option, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, + #[ts(optional = nullable)] + pub cwd: Option, + #[experimental(nested)] + #[ts(optional = nullable)] + pub approval_policy: Option, + /// Override where approval requests are routed for review on this thread + /// and subsequent turns. + #[ts(optional = nullable)] + pub approvals_reviewer: Option, + #[ts(optional = nullable)] + pub sandbox: Option, + /// Named profile selection for this thread. Cannot be combined with + /// `sandbox`. Use bounded `modifications` for supported turn/thread + /// adjustments instead of replacing the full permissions profile. + #[experimental("thread/start.permissions")] + #[ts(optional = nullable)] + pub permissions: Option, + #[ts(optional = nullable)] + pub config: Option>, + #[ts(optional = nullable)] + pub service_name: Option, + #[ts(optional = nullable)] + pub base_instructions: Option, + #[ts(optional = nullable)] + pub developer_instructions: Option, + #[ts(optional = nullable)] + pub personality: Option, + #[ts(optional = nullable)] + pub ephemeral: Option, + #[ts(optional = nullable)] + pub session_start_source: Option, + /// Optional client-supplied analytics source classification for this thread. + #[ts(optional = nullable)] + pub thread_source: Option, + /// Optional sticky environments for this thread. + /// + /// Omitted selects the default environment when environment access is + /// enabled. Empty disables environment access for turns that do not + /// provide a turn override. Non-empty selects the first environment as the + /// current turn environment. + #[experimental("thread/start.environments")] + #[ts(optional = nullable)] + pub environments: Option>, + #[experimental("thread/start.dynamicTools")] + #[ts(optional = nullable)] + pub dynamic_tools: Option>, + /// Test-only experimental field used to validate experimental gating and + /// schema filtering behavior in a stable way. + #[experimental("thread/start.mockExperimentalField")] + #[ts(optional = nullable)] + pub mock_experimental_field: Option, + /// If true, opt into emitting raw Responses API items on the event stream. + /// This is for internal use only (e.g. Codex Cloud). + #[experimental("thread/start.experimentalRawEvents")] + #[serde(default)] + pub experimental_raw_events: bool, + /// Deprecated and ignored by app-server. Kept only so older clients can + /// continue sending the field while rollout persistence always uses the + /// limited history policy. + #[experimental("thread/start.persistFullHistory")] + #[serde(default)] + pub persist_extended_history: bool, +} + +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MockExperimentalMethodParams { + /// Test-only payload field. + #[ts(optional = nullable)] + pub value: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MockExperimentalMethodResponse { + /// Echoes the input `value`. + pub echoed: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStartResponse { + pub thread: Thread, + pub model: String, + pub model_provider: String, + pub service_tier: Option, + pub cwd: AbsolutePathBuf, + /// Instruction source files currently loaded for this thread. + #[serde(default)] + pub instruction_sources: Vec, + #[experimental(nested)] + pub approval_policy: AskForApproval, + /// Reviewer currently used for approval requests on this thread. + pub approvals_reviewer: ApprovalsReviewer, + /// Legacy sandbox policy retained for compatibility. Experimental clients + /// should prefer `permissionProfile` when they need exact runtime + /// permissions. + pub sandbox: SandboxPolicy, + /// Full active permissions for this thread. `activePermissionProfile` + /// carries display/provenance metadata for this runtime profile. + #[experimental("thread/start.permissionProfile")] + #[serde(default)] + pub permission_profile: Option, + /// Named or implicit built-in profile that produced the active + /// permissions, when known. + #[experimental("thread/start.activePermissionProfile")] + #[serde(default)] + pub active_permission_profile: Option, + pub reasoning_effort: Option, +} + +#[derive( + Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// There are three ways to resume a thread: +/// 1. By thread_id: load the thread from disk by thread_id and resume it. +/// 2. By history: instantiate the thread from memory and resume it. +/// 3. By path: load the thread from disk by path and resume it. +/// +/// The precedence is: history > path > thread_id. +/// If using history or path, the thread_id param will be ignored. +/// +/// Prefer using thread_id whenever possible. +pub struct ThreadResumeParams { + pub thread_id: String, + + /// [UNSTABLE] FOR CODEX CLOUD - DO NOT USE. + /// If specified, the thread will be resumed with the provided history + /// instead of loaded from disk. + #[experimental("thread/resume.history")] + #[ts(optional = nullable)] + pub history: Option>, + + /// [UNSTABLE] Specify the rollout path to resume from. + /// If specified, the thread_id param will be ignored. + #[experimental("thread/resume.path")] + #[ts(optional = nullable)] + pub path: Option, + + /// Configuration overrides for the resumed thread, if any. + #[ts(optional = nullable)] + pub model: Option, + #[ts(optional = nullable)] + pub model_provider: Option, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, + #[ts(optional = nullable)] + pub cwd: Option, + #[experimental(nested)] + #[ts(optional = nullable)] + pub approval_policy: Option, + /// Override where approval requests are routed for review on this thread + /// and subsequent turns. + #[ts(optional = nullable)] + pub approvals_reviewer: Option, + #[ts(optional = nullable)] + pub sandbox: Option, + /// Named profile selection for the resumed thread. Cannot be combined + /// with `sandbox`. Use bounded `modifications` for supported thread + /// adjustments instead of replacing the full permissions profile. + #[experimental("thread/resume.permissions")] + #[ts(optional = nullable)] + pub permissions: Option, + #[ts(optional = nullable)] + pub config: Option>, + #[ts(optional = nullable)] + pub base_instructions: Option, + #[ts(optional = nullable)] + pub developer_instructions: Option, + #[ts(optional = nullable)] + pub personality: Option, + /// When true, return only thread metadata and live-resume state without + /// populating `thread.turns`. This is useful when the client plans to call + /// `thread/turns/list` immediately after resuming. + #[experimental("thread/resume.excludeTurns")] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub exclude_turns: bool, + /// Deprecated and ignored by app-server. Kept only so older clients can + /// continue sending the field while rollout persistence always uses the + /// limited history policy. + #[experimental("thread/resume.persistFullHistory")] + #[serde(default)] + pub persist_extended_history: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadResumeResponse { + pub thread: Thread, + pub model: String, + pub model_provider: String, + pub service_tier: Option, + pub cwd: AbsolutePathBuf, + /// Instruction source files currently loaded for this thread. + #[serde(default)] + pub instruction_sources: Vec, + #[experimental(nested)] + pub approval_policy: AskForApproval, + /// Reviewer currently used for approval requests on this thread. + pub approvals_reviewer: ApprovalsReviewer, + /// Legacy sandbox policy retained for compatibility. Experimental clients + /// should prefer `permissionProfile` when they need exact runtime + /// permissions. + pub sandbox: SandboxPolicy, + /// Full active permissions for this thread. `activePermissionProfile` + /// carries display/provenance metadata for this runtime profile. + #[experimental("thread/resume.permissionProfile")] + #[serde(default)] + pub permission_profile: Option, + /// Named or implicit built-in profile that produced the active + /// permissions, when known. + #[experimental("thread/resume.activePermissionProfile")] + #[serde(default)] + pub active_permission_profile: Option, + pub reasoning_effort: Option, +} + +#[derive( + Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// There are two ways to fork a thread: +/// 1. By thread_id: load the thread from disk by thread_id and fork it into a new thread. +/// 2. By path: load the thread from disk by path and fork it into a new thread. +/// +/// If using path, the thread_id param will be ignored. +/// +/// Prefer using thread_id whenever possible. +pub struct ThreadForkParams { + pub thread_id: String, + + /// [UNSTABLE] Specify the rollout path to fork from. + /// If specified, the thread_id param will be ignored. + #[experimental("thread/fork.path")] + #[ts(optional = nullable)] + pub path: Option, + + /// Configuration overrides for the forked thread, if any. + #[ts(optional = nullable)] + pub model: Option, + #[ts(optional = nullable)] + pub model_provider: Option, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, + #[ts(optional = nullable)] + pub cwd: Option, + #[experimental(nested)] + #[ts(optional = nullable)] + pub approval_policy: Option, + /// Override where approval requests are routed for review on this thread + /// and subsequent turns. + #[ts(optional = nullable)] + pub approvals_reviewer: Option, + #[ts(optional = nullable)] + pub sandbox: Option, + /// Named profile selection for the forked thread. Cannot be combined with + /// `sandbox`. Use bounded `modifications` for supported thread + /// adjustments instead of replacing the full permissions profile. + #[experimental("thread/fork.permissions")] + #[ts(optional = nullable)] + pub permissions: Option, + #[ts(optional = nullable)] + pub config: Option>, + #[ts(optional = nullable)] + pub base_instructions: Option, + #[ts(optional = nullable)] + pub developer_instructions: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub ephemeral: bool, + /// Optional client-supplied analytics source classification for this forked thread. + #[ts(optional = nullable)] + pub thread_source: Option, + /// When true, return only thread metadata and live fork state without + /// populating `thread.turns`. This is useful when the client plans to call + /// `thread/turns/list` immediately after forking. + #[experimental("thread/fork.excludeTurns")] + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub exclude_turns: bool, + /// Deprecated and ignored by app-server. Kept only so older clients can + /// continue sending the field while rollout persistence always uses the + /// limited history policy. + #[experimental("thread/fork.persistFullHistory")] + #[serde(default)] + pub persist_extended_history: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadForkResponse { + pub thread: Thread, + pub model: String, + pub model_provider: String, + pub service_tier: Option, + pub cwd: AbsolutePathBuf, + /// Instruction source files currently loaded for this thread. + #[serde(default)] + pub instruction_sources: Vec, + #[experimental(nested)] + pub approval_policy: AskForApproval, + /// Reviewer currently used for approval requests on this thread. + pub approvals_reviewer: ApprovalsReviewer, + /// Legacy sandbox policy retained for compatibility. Experimental clients + /// should prefer `permissionProfile` when they need exact runtime + /// permissions. + pub sandbox: SandboxPolicy, + /// Full active permissions for this thread. `activePermissionProfile` + /// carries display/provenance metadata for this runtime profile. + #[experimental("thread/fork.permissionProfile")] + #[serde(default)] + pub permission_profile: Option, + /// Named or implicit built-in profile that produced the active + /// permissions, when known. + #[experimental("thread/fork.activePermissionProfile")] + #[serde(default)] + pub active_permission_profile: Option, + pub reasoning_effort: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadArchiveParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadArchiveResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUnsubscribeParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUnsubscribeResponse { + pub status: ThreadUnsubscribeStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ThreadUnsubscribeStatus { + NotLoaded, + NotSubscribed, + Unsubscribed, +} + +/// Parameters for `thread/increment_elicitation`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadIncrementElicitationParams { + /// Thread whose out-of-band elicitation counter should be incremented. + pub thread_id: String, +} + +/// Response for `thread/increment_elicitation`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadIncrementElicitationResponse { + /// Current out-of-band elicitation count after the increment. + pub count: u64, + /// Whether timeout accounting is paused after applying the increment. + pub paused: bool, +} + +/// Parameters for `thread/decrement_elicitation`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadDecrementElicitationParams { + /// Thread whose out-of-band elicitation counter should be decremented. + pub thread_id: String, +} + +/// Response for `thread/decrement_elicitation`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadDecrementElicitationResponse { + /// Current out-of-band elicitation count after the decrement. + pub count: u64, + /// Whether timeout accounting remains paused after applying the decrement. + pub paused: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSetNameParams { + pub thread_id: String, + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUnarchiveParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadSetNameResponse {} + +v2_enum_from_core! { + pub enum ThreadGoalStatus from CoreThreadGoalStatus { + Active, + Paused, + BudgetLimited, + Complete, + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoal { + pub thread_id: String, + pub objective: String, + pub status: ThreadGoalStatus, + #[ts(type = "number | null")] + pub token_budget: Option, + #[ts(type = "number")] + pub tokens_used: i64, + #[ts(type = "number")] + pub time_used_seconds: i64, + #[ts(type = "number")] + pub created_at: i64, + #[ts(type = "number")] + pub updated_at: i64, +} + +impl From for ThreadGoal { + fn from(value: codex_protocol::protocol::ThreadGoal) -> Self { + Self { + thread_id: value.thread_id.to_string(), + objective: value.objective, + status: value.status.into(), + token_budget: value.token_budget, + tokens_used: value.tokens_used, + time_used_seconds: value.time_used_seconds, + created_at: value.created_at, + updated_at: value.updated_at, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalSetParams { + pub thread_id: String, + #[ts(optional = nullable)] + pub objective: Option, + #[ts(optional = nullable)] + pub status: Option, + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable, type = "number | null")] + pub token_budget: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalSetResponse { + pub goal: ThreadGoal, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalGetParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalGetResponse { + pub goal: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalClearParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalClearResponse { + pub cleared: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMetadataUpdateParams { + pub thread_id: String, + /// Patch the stored Git metadata for this thread. + /// Omit a field to leave it unchanged, set it to `null` to clear it, or + /// provide a string to replace the stored value. + #[ts(optional = nullable)] + pub git_info: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMetadataGitInfoUpdateParams { + /// Omit to leave the stored commit unchanged, set to `null` to clear it, + /// or provide a non-empty string to replace it. + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option" + )] + #[ts(optional = nullable, type = "string | null")] + pub sha: Option>, + /// Omit to leave the stored branch unchanged, set to `null` to clear it, + /// or provide a non-empty string to replace it. + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option" + )] + #[ts(optional = nullable, type = "string | null")] + pub branch: Option>, + /// Omit to leave the stored origin URL unchanged, set to `null` to clear it, + /// or provide a non-empty string to replace it. + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option" + )] + #[ts(optional = nullable, type = "string | null")] + pub origin_url: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMetadataUpdateResponse { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +#[ts(rename_all = "lowercase")] +pub enum ThreadMemoryMode { + Enabled, + Disabled, +} + +impl ThreadMemoryMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Enabled => "enabled", + Self::Disabled => "disabled", + } + } + + pub fn to_core(self) -> codex_protocol::protocol::ThreadMemoryMode { + match self { + Self::Enabled => codex_protocol::protocol::ThreadMemoryMode::Enabled, + Self::Disabled => codex_protocol::protocol::ThreadMemoryMode::Disabled, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMemoryModeSetParams { + pub thread_id: String, + pub mode: ThreadMemoryMode, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadMemoryModeSetResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MemoryResetResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUnarchiveResponse { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadCompactStartParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadCompactStartResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadShellCommandParams { + pub thread_id: String, + /// Shell command string evaluated by the thread's configured shell. + /// Unlike `command/exec`, this intentionally preserves shell syntax + /// such as pipes, redirects, and quoting. This runs unsandboxed with full + /// access rather than inheriting the thread sandbox policy. + pub command: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadShellCommandResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadApproveGuardianDeniedActionParams { + pub thread_id: String, + /// Serialized `codex_protocol::protocol::GuardianAssessmentEvent`. + pub event: JsonValue, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadApproveGuardianDeniedActionResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadBackgroundTerminalsCleanParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadBackgroundTerminalsCleanResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRollbackParams { + pub thread_id: String, + /// The number of turns to drop from the end of the thread. Must be >= 1. + /// + /// This only modifies the thread's history and does not revert local file changes + /// that have been made by the agent. Clients are responsible for reverting these changes. + pub num_turns: u32, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadRollbackResponse { + /// The updated thread after applying the rollback, with `turns` populated. + /// + /// The ThreadItems stored in each Turn are lossy since we explicitly do not + /// persist all agent interactions, such as command executions. This is the same + /// behavior as `thread/resume`. + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + #[ts(optional = nullable)] + pub limit: Option, + /// Optional sort key; defaults to created_at. + #[ts(optional = nullable)] + pub sort_key: Option, + /// Optional sort direction; defaults to descending (newest first). + #[ts(optional = nullable)] + pub sort_direction: Option, + /// Optional provider filter; when set, only sessions recorded under these + /// providers are returned. When present but empty, includes all providers. + #[ts(optional = nullable)] + pub model_providers: Option>, + /// Optional source filter; when set, only sessions from these source kinds + /// are returned. When omitted or empty, defaults to interactive sources. + #[ts(optional = nullable)] + pub source_kinds: Option>, + /// Optional archived filter; when set to true, only archived threads are returned. + /// If false or null, only non-archived threads are returned. + #[ts(optional = nullable)] + pub archived: Option, + /// Optional cwd filter or filters; when set, only threads whose session cwd + /// exactly matches one of these paths are returned. + #[ts(optional = nullable, type = "string | Array | null")] + pub cwd: Option, + /// If true, return from the state DB without scanning JSONL rollouts to + /// repair thread metadata. Omitted or false preserves scan-and-repair + /// behavior. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub use_state_db_only: bool, + /// Optional substring filter for the extracted thread title. + #[ts(optional = nullable)] + pub search_term: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(untagged)] +pub enum ThreadListCwdFilter { + One(String), + Many(Vec), +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum ThreadSourceKind { + Cli, + #[serde(rename = "vscode")] + #[ts(rename = "vscode")] + VsCode, + Exec, + AppServer, + SubAgent, + SubAgentReview, + SubAgentCompact, + SubAgentThreadSpawn, + SubAgentOther, + Unknown, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum ThreadSortKey { + CreatedAt, + UpdatedAt, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/")] +pub enum SortDirection { + Asc, + Desc, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// if None, there are no more items to return. + pub next_cursor: Option, + /// Opaque cursor to pass as `cursor` when reversing `sortDirection`. + /// This is only populated when the page contains at least one thread. + /// Use it with the opposite `sortDirection`; for timestamp sorts it anchors + /// at the start of the page timestamp so same-second updates are not skipped. + pub backwards_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadLoadedListParams { + /// Opaque pagination cursor returned by a previous call. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional page size; defaults to no limit. + #[ts(optional = nullable)] + pub limit: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadLoadedListResponse { + /// Thread ids for sessions currently loaded in memory. + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// if None, there are no more items to return. + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ThreadStatus { + NotLoaded, + Idle, + SystemError, + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + Active { + active_flags: Vec, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum ThreadActiveFlag { + WaitingOnApproval, + WaitingOnUserInput, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadReadParams { + pub thread_id: String, + /// When true, include turns and their items from rollout history. + #[serde(default)] + pub include_turns: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadReadResponse { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadInjectItemsParams { + pub thread_id: String, + /// Raw Responses API items to append to the thread's model-visible history. + pub items: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadInjectItemsResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadTurnsListParams { + pub thread_id: String, + /// Opaque cursor to pass to the next call to continue after the last turn. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional turn page size. + #[ts(optional = nullable)] + pub limit: Option, + /// Optional turn pagination direction; defaults to descending. + #[ts(optional = nullable)] + pub sort_direction: Option, + /// How much item detail to include for each returned turn; defaults to summary. + #[ts(optional = nullable)] + pub items_view: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadTurnsListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last turn. + /// if None, there are no more turns to return. + pub next_cursor: Option, + /// Opaque cursor to pass as `cursor` when reversing `sortDirection`. + /// This is only populated when the page contains at least one turn. + /// Use it with the opposite `sortDirection` to include the anchor turn again + /// and catch updates to that turn. + pub backwards_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadTurnsItemsListParams { + pub thread_id: String, + pub turn_id: String, + /// Opaque cursor to pass to the next call to continue after the last item. + #[ts(optional = nullable)] + pub cursor: Option, + /// Optional item page size. + #[ts(optional = nullable)] + pub limit: Option, + /// Optional item pagination direction; defaults to ascending. + #[ts(optional = nullable)] + pub sort_direction: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadTurnsItemsListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// if None, there are no more items to return. + pub next_cursor: Option, + /// Opaque cursor to pass as `cursor` when reversing `sortDirection`. + /// This is only populated when the page contains at least one item. + pub backwards_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadTokenUsageUpdatedNotification { + pub thread_id: String, + pub turn_id: String, + pub token_usage: ThreadTokenUsage, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadTokenUsage { + pub total: TokenUsageBreakdown, + pub last: TokenUsageBreakdown, + // TODO(aibrahim): make this not optional + #[ts(type = "number | null")] + pub model_context_window: Option, +} + +impl From for ThreadTokenUsage { + fn from(value: CoreTokenUsageInfo) -> Self { + Self { + total: value.total_token_usage.into(), + last: value.last_token_usage.into(), + model_context_window: value.model_context_window, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TokenUsageBreakdown { + #[ts(type = "number")] + pub total_tokens: i64, + #[ts(type = "number")] + pub input_tokens: i64, + #[ts(type = "number")] + pub cached_input_tokens: i64, + #[ts(type = "number")] + pub output_tokens: i64, + #[ts(type = "number")] + pub reasoning_output_tokens: i64, +} + +impl From for TokenUsageBreakdown { + fn from(value: CoreTokenUsage) -> Self { + Self { + total_tokens: value.total_tokens, + input_tokens: value.input_tokens, + cached_input_tokens: value.cached_input_tokens, + output_tokens: value.output_tokens, + reasoning_output_tokens: value.reasoning_output_tokens, + } + } +} + +// Thread/Turn lifecycle notifications and item progress events +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStartedNotification { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStatusChangedNotification { + pub thread_id: String, + pub status: ThreadStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadArchivedNotification { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadUnarchivedNotification { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadClosedNotification { + pub thread_id: String, +} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadNameUpdatedNotification { + pub thread_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub thread_name: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalUpdatedNotification { + pub thread_id: String, + pub turn_id: Option, + pub goal: ThreadGoal, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalClearedNotification { + pub thread_id: String, +} + +/// Deprecated: Use `ContextCompaction` item type instead. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ContextCompactedNotification { + pub thread_id: String, + pub turn_id: String, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs new file mode 100644 index 0000000000..f0c518adf8 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread_data.rs @@ -0,0 +1,196 @@ +use super::CodexErrorInfo; +use super::ThreadItem; +use super::ThreadStatus; +use super::TurnStatus; +use codex_protocol::protocol::SessionSource as CoreSessionSource; +use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::ThreadSource as CoreThreadSource; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use std::path::PathBuf; +use thiserror::Error; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +#[derive(Default)] +pub enum SessionSource { + Cli, + #[serde(rename = "vscode")] + #[ts(rename = "vscode")] + #[default] + VsCode, + Exec, + AppServer, + Custom(String), + SubAgent(CoreSubAgentSource), + #[serde(other)] + Unknown, +} + +impl From for SessionSource { + fn from(value: CoreSessionSource) -> Self { + match value { + CoreSessionSource::Cli => SessionSource::Cli, + CoreSessionSource::VSCode => SessionSource::VsCode, + CoreSessionSource::Exec => SessionSource::Exec, + CoreSessionSource::Mcp => SessionSource::AppServer, + CoreSessionSource::Custom(source) => SessionSource::Custom(source), + // We do not want to render those at the app-server level. + CoreSessionSource::Internal(_) => SessionSource::Unknown, + CoreSessionSource::SubAgent(sub) => SessionSource::SubAgent(sub), + CoreSessionSource::Unknown => SessionSource::Unknown, + } + } +} + +impl From for CoreSessionSource { + fn from(value: SessionSource) -> Self { + match value { + SessionSource::Cli => CoreSessionSource::Cli, + SessionSource::VsCode => CoreSessionSource::VSCode, + SessionSource::Exec => CoreSessionSource::Exec, + SessionSource::AppServer => CoreSessionSource::Mcp, + SessionSource::Custom(source) => CoreSessionSource::Custom(source), + SessionSource::SubAgent(sub) => CoreSessionSource::SubAgent(sub), + SessionSource::Unknown => CoreSessionSource::Unknown, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case", export_to = "v2/")] +pub enum ThreadSource { + User, + Subagent, + MemoryConsolidation, +} + +impl From for ThreadSource { + fn from(value: CoreThreadSource) -> Self { + match value { + CoreThreadSource::User => ThreadSource::User, + CoreThreadSource::Subagent => ThreadSource::Subagent, + CoreThreadSource::MemoryConsolidation => ThreadSource::MemoryConsolidation, + } + } +} + +impl From for CoreThreadSource { + fn from(value: ThreadSource) -> Self { + match value { + ThreadSource::User => CoreThreadSource::User, + ThreadSource::Subagent => CoreThreadSource::Subagent, + ThreadSource::MemoryConsolidation => CoreThreadSource::MemoryConsolidation, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GitInfo { + pub sha: Option, + pub branch: Option, + pub origin_url: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Thread { + pub id: String, + /// Session id shared by threads that belong to the same session tree. + pub session_id: String, + /// Source thread id when this thread was created by forking another thread. + pub forked_from_id: Option, + /// Usually the first user message in the thread, if available. + pub preview: String, + /// Whether the thread is ephemeral and should not be materialized on disk. + pub ephemeral: bool, + /// Model provider used for this thread (for example, 'openai'). + pub model_provider: String, + /// Unix timestamp (in seconds) when the thread was created. + #[ts(type = "number")] + pub created_at: i64, + /// Unix timestamp (in seconds) when the thread was last updated. + #[ts(type = "number")] + pub updated_at: i64, + /// Current runtime status for the thread. + pub status: ThreadStatus, + /// [UNSTABLE] Path to the thread on disk. + pub path: Option, + /// Working directory captured for the thread. + pub cwd: AbsolutePathBuf, + /// Version of the CLI that created the thread. + pub cli_version: String, + /// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). + pub source: SessionSource, + /// Optional analytics source classification for this thread. + pub thread_source: Option, + /// Optional random unique nickname assigned to an AgentControl-spawned sub-agent. + pub agent_nickname: Option, + /// Optional role (agent_role) assigned to an AgentControl-spawned sub-agent. + pub agent_role: Option, + /// Optional Git metadata captured when the thread was created. + pub git_info: Option, + /// Optional user-facing thread title. + pub name: Option, + /// Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` + /// (when `includeTurns` is true) responses. + /// For all other responses and notifications returning a Thread, + /// the turns field will be an empty list. + pub turns: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Turn { + pub id: String, + /// Thread items currently included in this turn payload. + pub items: Vec, + /// Describes how much of `items` has been loaded for this turn. + #[serde(default)] + pub items_view: TurnItemsView, + pub status: TurnStatus, + /// Only populated when the Turn's status is failed. + pub error: Option, + /// Unix timestamp (in seconds) when the turn started. + #[ts(type = "number | null")] + pub started_at: Option, + /// Unix timestamp (in seconds) when the turn completed. + #[ts(type = "number | null")] + pub completed_at: Option, + /// Duration between turn start and completion in milliseconds, if known. + #[ts(type = "number | null")] + pub duration_ms: Option, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum TurnItemsView { + /// `items` was not loaded for this turn. The field is intentionally empty. + NotLoaded, + /// `items` contains only a display summary for this turn. + Summary, + /// `items` contains every ThreadItem available from persisted app-server history for this turn. + #[default] + Full, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Error)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +#[error("{message}")] +pub struct TurnError { + pub message: String, + pub codex_error_info: Option, + #[serde(default)] + pub additional_details: Option, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs new file mode 100644 index 0000000000..61a09bfbf5 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -0,0 +1,389 @@ +use super::ApprovalsReviewer; +use super::AskForApproval; +use super::PermissionProfileSelectionParams; +use super::SandboxPolicy; +use super::Turn; +use codex_experimental_api_macros::ExperimentalApi; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::plan_tool::PlanItemArg as CorePlanItemArg; +use codex_protocol::plan_tool::StepStatus as CorePlanStepStatus; +use codex_protocol::user_input::ByteRange as CoreByteRange; +use codex_protocol::user_input::TextElement as CoreTextElement; +use codex_protocol::user_input::UserInput as CoreUserInput; +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use std::collections::HashMap; +use std::path::PathBuf; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum TurnStatus { + Completed, + Interrupted, + Failed, + InProgress, +} + +// Turn APIs +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnEnvironmentParams { + pub environment_id: String, + pub cwd: AbsolutePathBuf, +} + +#[derive( + Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnStartParams { + pub thread_id: String, + pub input: Vec, + /// Optional turn-scoped Responses API client metadata. + #[experimental("turn/start.responsesapiClientMetadata")] + #[ts(optional = nullable)] + pub responsesapi_client_metadata: Option>, + /// Optional turn-scoped environments. + /// + /// Omitted uses the thread sticky environments. Empty disables + /// environment access for this turn. Non-empty selects the first + /// environment as the current turn environment for this turn. + #[experimental("turn/start.environments")] + #[ts(optional = nullable)] + pub environments: Option>, + /// Override the working directory for this turn and subsequent turns. + #[ts(optional = nullable)] + pub cwd: Option, + /// Override the approval policy for this turn and subsequent turns. + #[experimental(nested)] + #[ts(optional = nullable)] + pub approval_policy: Option, + /// Override where approval requests are routed for review on this turn and + /// subsequent turns. + #[ts(optional = nullable)] + pub approvals_reviewer: Option, + /// Override the sandbox policy for this turn and subsequent turns. + #[ts(optional = nullable)] + pub sandbox_policy: Option, + /// Select a named permissions profile for this turn and subsequent turns. + /// Cannot be combined with `sandboxPolicy`. Use bounded `modifications` + /// for supported turn adjustments instead of replacing the full + /// permissions profile. + #[experimental("turn/start.permissions")] + #[ts(optional = nullable)] + pub permissions: Option, + /// Override the model for this turn and subsequent turns. + #[ts(optional = nullable)] + pub model: Option, + /// Override the service tier for this turn and subsequent turns. + #[serde( + default, + deserialize_with = "crate::protocol::serde_helpers::deserialize_double_option", + serialize_with = "crate::protocol::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable)] + pub service_tier: Option>, + /// Override the reasoning effort for this turn and subsequent turns. + #[ts(optional = nullable)] + pub effort: Option, + /// Override the reasoning summary for this turn and subsequent turns. + #[ts(optional = nullable)] + pub summary: Option, + /// Override the personality for this turn and subsequent turns. + #[ts(optional = nullable)] + pub personality: Option, + /// Optional JSON Schema used to constrain the final assistant message for + /// this turn. + #[ts(optional = nullable)] + pub output_schema: Option, + + /// EXPERIMENTAL - Set a pre-set collaboration mode. + /// Takes precedence over model, reasoning_effort, and developer instructions if set. + /// + /// For `collaboration_mode.settings.developer_instructions`, `null` means + /// "use the built-in instructions for the selected mode". + #[experimental("turn/start.collaborationMode")] + #[ts(optional = nullable)] + pub collaboration_mode: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnStartResponse { + pub turn: Turn, +} + +#[derive( + Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi, +)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnSteerParams { + pub thread_id: String, + pub input: Vec, + /// Optional turn-scoped Responses API client metadata. + #[experimental("turn/steer.responsesapiClientMetadata")] + #[ts(optional = nullable)] + pub responsesapi_client_metadata: Option>, + /// Required active turn id precondition. The request fails when it does not + /// match the currently active turn. + pub expected_turn_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnSteerResponse { + pub turn_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnInterruptParams { + pub thread_id: String, + pub turn_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnInterruptResponse {} + +// User input types +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ByteRange { + pub start: usize, + pub end: usize, +} + +impl From for ByteRange { + fn from(value: CoreByteRange) -> Self { + Self { + start: value.start, + end: value.end, + } + } +} + +impl From for CoreByteRange { + fn from(value: ByteRange) -> Self { + Self { + start: value.start, + end: value.end, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TextElement { + /// Byte range in the parent `text` buffer that this element occupies. + pub byte_range: ByteRange, + /// Optional human-readable placeholder for the element, displayed in the UI. + placeholder: Option, +} + +impl TextElement { + pub fn new(byte_range: ByteRange, placeholder: Option) -> Self { + Self { + byte_range, + placeholder, + } + } + + pub fn set_placeholder(&mut self, placeholder: Option) { + self.placeholder = placeholder; + } + + pub fn placeholder(&self) -> Option<&str> { + self.placeholder.as_deref() + } +} + +impl From for TextElement { + fn from(value: CoreTextElement) -> Self { + Self::new( + value.byte_range.into(), + value._placeholder_for_conversion_only().map(str::to_string), + ) + } +} + +impl From for CoreTextElement { + fn from(value: TextElement) -> Self { + Self::new(value.byte_range.into(), value.placeholder) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum UserInput { + Text { + text: String, + /// UI-defined spans within `text` used to render or persist special elements. + #[serde(default)] + text_elements: Vec, + }, + Image { + url: String, + }, + LocalImage { + path: PathBuf, + }, + Skill { + name: String, + path: PathBuf, + }, + Mention { + name: String, + path: String, + }, +} + +impl UserInput { + pub fn into_core(self) -> CoreUserInput { + match self { + UserInput::Text { + text, + text_elements, + } => CoreUserInput::Text { + text, + text_elements: text_elements.into_iter().map(Into::into).collect(), + }, + UserInput::Image { url } => CoreUserInput::Image { image_url: url }, + UserInput::LocalImage { path } => CoreUserInput::LocalImage { path }, + UserInput::Skill { name, path } => CoreUserInput::Skill { name, path }, + UserInput::Mention { name, path } => CoreUserInput::Mention { name, path }, + } + } +} + +impl From for UserInput { + fn from(value: CoreUserInput) -> Self { + match value { + CoreUserInput::Text { + text, + text_elements, + } => UserInput::Text { + text, + text_elements: text_elements.into_iter().map(Into::into).collect(), + }, + CoreUserInput::Image { image_url } => UserInput::Image { url: image_url }, + CoreUserInput::LocalImage { path } => UserInput::LocalImage { path }, + CoreUserInput::Skill { name, path } => UserInput::Skill { name, path }, + CoreUserInput::Mention { name, path } => UserInput::Mention { name, path }, + _ => unreachable!("unsupported user input variant"), + } + } +} + +impl UserInput { + pub fn text_char_count(&self) -> usize { + match self { + UserInput::Text { text, .. } => text.chars().count(), + UserInput::Image { .. } + | UserInput::LocalImage { .. } + | UserInput::Skill { .. } + | UserInput::Mention { .. } => 0, + } + } +} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnStartedNotification { + pub thread_id: String, + pub turn: Turn, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Usage { + pub input_tokens: i32, + pub cached_input_tokens: i32, + pub output_tokens: i32, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnCompletedNotification { + pub thread_id: String, + pub turn: Turn, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +/// Notification that the turn-level unified diff has changed. +/// Contains the latest aggregated diff across all file changes in the turn. +pub struct TurnDiffUpdatedNotification { + pub thread_id: String, + pub turn_id: String, + pub diff: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnPlanUpdatedNotification { + pub thread_id: String, + pub turn_id: String, + pub explanation: Option, + pub plan: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnPlanStep { + pub step: String, + pub status: TurnPlanStepStatus, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum TurnPlanStepStatus { + Pending, + InProgress, + Completed, +} + +impl From for TurnPlanStep { + fn from(value: CorePlanItemArg) -> Self { + Self { + step: value.step, + status: value.status.into(), + } + } +} + +impl From for TurnPlanStepStatus { + fn from(value: CorePlanStepStatus) -> Self { + match value { + CorePlanStepStatus::Pending => Self::Pending, + CorePlanStepStatus::InProgress => Self::InProgress, + CorePlanStepStatus::Completed => Self::Completed, + } + } +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2/windows_sandbox.rs b/codex-rs/app-server-protocol/src/protocol/v2/windows_sandbox.rs new file mode 100644 index 0000000000..3e090c7bfd --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/windows_sandbox.rs @@ -0,0 +1,63 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WindowsWorldWritableWarningNotification { + pub sample_paths: Vec, + pub extra_count: usize, + pub failed_scan: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum WindowsSandboxSetupMode { + Elevated, + Unelevated, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum WindowsSandboxReadiness { + Ready, + NotConfigured, + UpdateRequired, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WindowsSandboxSetupStartParams { + pub mode: WindowsSandboxSetupMode, + #[ts(optional = nullable)] + pub cwd: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WindowsSandboxSetupStartResponse { + pub started: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WindowsSandboxReadinessResponse { + pub status: WindowsSandboxReadiness, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WindowsSandboxSetupCompletedNotification { + pub mode: WindowsSandboxSetupMode, + pub success: bool, + pub error: Option, +} diff --git a/codex-rs/app-server-test-client/Cargo.toml b/codex-rs/app-server-test-client/Cargo.toml index 32d2588fdf..603a5caf22 100644 --- a/codex-rs/app-server-test-client/Cargo.toml +++ b/codex-rs/app-server-test-client/Cargo.toml @@ -23,3 +23,7 @@ tracing-subscriber = { workspace = true } tungstenite = { workspace = true } url = { workspace = true } uuid = { workspace = true, features = ["v4"] } + +[lib] +test = false +doctest = false diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index edea431c61..fd9ecc845d 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1551,6 +1551,7 @@ impl CodexClient { }, capabilities: Some(InitializeCapabilities { experimental_api, + request_attestation: false, opt_out_notification_methods: Some( NOTIFICATIONS_TO_OPT_OUT .iter() @@ -1945,6 +1946,7 @@ impl CodexClient { thread_id, turn_id, item_id, + started_at_ms: _, approval_id, reason, network_approval_context, @@ -2020,6 +2022,7 @@ impl CodexClient { thread_id, turn_id, item_id, + started_at_ms: _, reason, grant_root, } = params; diff --git a/codex-rs/app-server-transport/Cargo.toml b/codex-rs/app-server-transport/Cargo.toml index d1f89c5b59..175890962e 100644 --- a/codex-rs/app-server-transport/Cargo.toml +++ b/codex-rs/app-server-transport/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [lib] name = "codex_app_server_transport" path = "src/lib.rs" +doctest = false [lints] workspace = true diff --git a/codex-rs/app-server-transport/src/lib.rs b/codex-rs/app-server-transport/src/lib.rs index 0a5c080acc..e3f39f60ce 100644 --- a/codex-rs/app-server-transport/src/lib.rs +++ b/codex-rs/app-server-transport/src/lib.rs @@ -11,6 +11,7 @@ pub use transport::AppServerTransportParseError; pub use transport::CHANNEL_CAPACITY; pub use transport::ConnectionOrigin; pub use transport::RemoteControlHandle; +pub use transport::RemoteControlStartConfig; pub use transport::TransportEvent; pub use transport::app_server_control_socket_path; pub use transport::auth; diff --git a/codex-rs/app-server-transport/src/transport/auth.rs b/codex-rs/app-server-transport/src/transport/auth.rs index 9ec025f66f..eeccf21ed3 100644 --- a/codex-rs/app-server-transport/src/transport/auth.rs +++ b/codex-rs/app-server-transport/src/transport/auth.rs @@ -263,7 +263,7 @@ pub fn policy_from_settings( Ok(WebsocketAuthPolicy { mode }) } -pub(crate) fn should_warn_about_unauthenticated_non_loopback_listener( +pub(crate) fn is_unauthenticated_non_loopback_listener( bind_address: SocketAddr, policy: &WebsocketAuthPolicy, ) -> bool { @@ -482,17 +482,17 @@ mod tests { } #[test] - fn warns_about_unauthenticated_non_loopback_listener() { + fn detects_unauthenticated_non_loopback_listener() { let policy = WebsocketAuthPolicy::default(); - assert!(should_warn_about_unauthenticated_non_loopback_listener( + assert!(is_unauthenticated_non_loopback_listener( "0.0.0.0:8765".parse().unwrap(), &policy, )); - assert!(!should_warn_about_unauthenticated_non_loopback_listener( + assert!(!is_unauthenticated_non_loopback_listener( "127.0.0.1:8765".parse().unwrap(), &policy, )); - assert!(!should_warn_about_unauthenticated_non_loopback_listener( + assert!(!is_unauthenticated_non_loopback_listener( "0.0.0.0:8765".parse().unwrap(), &WebsocketAuthPolicy { mode: Some(WebsocketAuthMode::CapabilityToken { diff --git a/codex-rs/app-server-transport/src/transport/mod.rs b/codex-rs/app-server-transport/src/transport/mod.rs index e1590ab43a..3e76069f43 100644 --- a/codex-rs/app-server-transport/src/transport/mod.rs +++ b/codex-rs/app-server-transport/src/transport/mod.rs @@ -31,6 +31,7 @@ mod unix_socket_tests; mod websocket; pub use remote_control::RemoteControlHandle; +pub use remote_control::RemoteControlStartConfig; pub use remote_control::start_remote_control; pub use stdio::start_stdio_connection; pub use unix_socket::start_control_socket_acceptor; @@ -171,14 +172,6 @@ pub enum ConnectionOrigin { RemoteControl, } -impl ConnectionOrigin { - pub fn allows_device_key_requests(self) -> bool { - // Device-key endpoints are only for local connections that own the app-server instance. - // Do not include remote transports such as SSH or remote-control websocket connections. - matches!(self, Self::Stdio | Self::InProcess) - } -} - static CONNECTION_ID_COUNTER: AtomicU64 = AtomicU64::new(0); fn next_connection_id() -> ConnectionId { diff --git a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs index fb7f727b83..60dfc3d845 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs @@ -19,6 +19,7 @@ const REQUEST_ID_HEADER: &str = "x-request-id"; const OAI_REQUEST_ID_HEADER: &str = "x-oai-request-id"; const CF_RAY_HEADER: &str = "cf-ray"; pub(super) const REMOTE_CONTROL_ACCOUNT_ID_HEADER: &str = "chatgpt-account-id"; +pub(super) const REMOTE_CONTROL_INSTALLATION_ID_HEADER: &str = "x-codex-installation-id"; #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct RemoteControlEnrollment { @@ -193,6 +194,7 @@ pub(crate) fn format_headers(headers: &HeaderMap) -> String { pub(super) async fn enroll_remote_control_server( remote_control_target: &RemoteControlTarget, auth: &RemoteControlConnectionAuth, + installation_id: &str, ) -> io::Result { let enroll_url = &remote_control_target.enroll_url; let server_name = gethostname().to_string_lossy().trim().to_string(); @@ -201,6 +203,7 @@ pub(super) async fn enroll_remote_control_server( os: std::env::consts::OS, arch: std::env::consts::ARCH, app_server_version: env!("CARGO_PKG_VERSION"), + installation_id: installation_id.to_string(), }; let client = build_reqwest_client(); let mut auth_headers = HeaderMap::new(); @@ -210,6 +213,7 @@ pub(super) async fn enroll_remote_control_server( .timeout(REMOTE_CONTROL_ENROLL_TIMEOUT) .headers(auth_headers) .header(REMOTE_CONTROL_ACCOUNT_ID_HEADER, &auth.account_id) + .header(REMOTE_CONTROL_INSTALLATION_ID_HEADER, installation_id) .json(&request); let response = http_request.send().await.map_err(|err| { @@ -459,6 +463,7 @@ mod tests { auth_provider: codex_model_provider::unauthenticated_auth_provider(), account_id: "account_id".to_string(), }, + "11111111-1111-4111-8111-111111111111", ) .await .expect_err("invalid response should fail to parse"); diff --git a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs index 87405efa4f..9ffe8b60bb 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs @@ -28,6 +28,11 @@ use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use tracing::warn; +pub struct RemoteControlStartConfig { + pub remote_control_url: String, + pub installation_id: String, +} + pub(super) struct QueuedServerEnvelope { pub(super) event: ServerEvent, pub(super) client_id: ClientId, @@ -62,7 +67,7 @@ impl RemoteControlHandle { } pub async fn start_remote_control( - remote_control_url: String, + config: RemoteControlStartConfig, state_db: Option>, auth_manager: Arc, transport_event_tx: mpsc::Sender, @@ -77,7 +82,7 @@ pub async fn start_remote_control( warn!("remote control disabled because sqlite state db is unavailable"); } let remote_control_target = if initial_enabled { - Some(normalize_remote_control_url(&remote_control_url)?) + Some(normalize_remote_control_url(&config.remote_control_url)?) } else { None }; @@ -89,14 +94,18 @@ pub async fn start_remote_control( } else { RemoteControlConnectionStatus::Disabled }, + installation_id: config.installation_id.clone(), environment_id: None, }; let (status_tx, _status_rx) = watch::channel(initial_status); let status_publisher = RemoteControlStatusPublisher::new(status_tx.clone()); let join_handle = tokio::spawn(async move { RemoteControlWebsocket::new( - remote_control_url, - remote_control_target, + websocket::RemoteControlWebsocketConfig { + remote_control_url: config.remote_control_url, + installation_id: config.installation_id, + remote_control_target, + }, state_db, auth_manager, RemoteControlChannels { diff --git a/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs b/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs index dea5404ab1..cee646b0d9 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs @@ -19,6 +19,7 @@ pub(super) struct EnrollRemoteServerRequest { pub(super) os: &'static str, pub(super) arch: &'static str, pub(super) app_server_version: &'static str, + pub(super) installation_id: String, } #[derive(Debug, Deserialize)] diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 5fd3caa401..88b7798827 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -1,4 +1,5 @@ use super::enroll::REMOTE_CONTROL_ACCOUNT_ID_HEADER; +use super::enroll::REMOTE_CONTROL_INSTALLATION_ID_HEADER; use super::enroll::RemoteControlEnrollment; use super::enroll::load_persisted_remote_control_enrollment; use super::enroll::update_persisted_remote_control_enrollment; @@ -56,6 +57,8 @@ use tokio_tungstenite::accept_hdr_async; use tokio_tungstenite::tungstenite; use tokio_util::sync::CancellationToken; +const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; + fn remote_control_auth_manager() -> Arc { auth_manager_from_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) } @@ -131,6 +134,7 @@ async fn expect_remote_control_status( if let Some(expected_status) = expected_status { assert_eq!(status.status, expected_status); } + assert_eq!(status.installation_id, TEST_INSTALLATION_ID); assert_eq!(status.environment_id.as_deref(), expected_environment_id); } @@ -173,7 +177,10 @@ async fn remote_control_transport_manages_virtual_clients_and_routes_messages() mpsc::channel::(CHANNEL_CAPACITY); let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( - remote_control_url, + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, Some(remote_control_state_runtime(&codex_home).await), remote_control_auth_manager(), transport_event_tx, @@ -449,7 +456,10 @@ async fn remote_control_transport_reconnects_after_disconnect() { mpsc::channel::(CHANNEL_CAPACITY); let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( - remote_control_url, + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, Some(remote_control_state_runtime(&codex_home).await), remote_control_auth_manager(), transport_event_tx, @@ -528,7 +538,10 @@ async fn remote_control_start_allows_remote_control_invalid_url_when_disabled() mpsc::channel::(CHANNEL_CAPACITY); let shutdown_token = CancellationToken::new(); let (remote_task, _remote_handle) = start_remote_control( - "https://internal.example.com/backend-api/".to_string(), + RemoteControlStartConfig { + remote_control_url: "https://internal.example.com/backend-api/".to_string(), + installation_id: TEST_INSTALLATION_ID.to_string(), + }, /*state_db*/ None, remote_control_auth_manager(), transport_event_tx, @@ -564,7 +577,10 @@ async fn remote_control_start_allows_missing_auth_when_enabled() { mpsc::channel::(CHANNEL_CAPACITY); let shutdown_token = CancellationToken::new(); let (remote_task, _remote_handle) = start_remote_control( - remote_control_url, + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, Some(remote_control_state_runtime(&codex_home).await), auth_manager, transport_event_tx, @@ -596,7 +612,10 @@ async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled( mpsc::channel::(CHANNEL_CAPACITY); let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( - remote_control_url, + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, /*state_db*/ None, remote_control_auth_manager(), transport_event_tx, @@ -611,6 +630,7 @@ async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled( status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Disabled, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } ); @@ -645,7 +665,10 @@ async fn remote_control_handle_set_enabled_stops_and_restarts_connections() { mpsc::channel::(CHANNEL_CAPACITY); let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( - remote_control_url, + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, Some(remote_control_state_runtime(&codex_home).await), remote_control_auth_manager(), transport_event_tx, @@ -672,6 +695,7 @@ async fn remote_control_handle_set_enabled_stops_and_restarts_connections() { &mut status_rx, RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connected, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_test".to_string()), }, ) @@ -682,6 +706,7 @@ async fn remote_control_handle_set_enabled_stops_and_restarts_connections() { &mut status_rx, RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Disabled, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, }, ) @@ -698,6 +723,7 @@ async fn remote_control_handle_set_enabled_stops_and_restarts_connections() { &mut status_rx, RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_test".to_string()), }, ) @@ -729,7 +755,10 @@ async fn remote_control_transport_clears_outgoing_buffer_when_backend_acks() { mpsc::channel::(CHANNEL_CAPACITY); let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( - remote_control_url, + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, Some(remote_control_state_runtime(&codex_home).await), remote_control_auth_manager(), transport_event_tx, @@ -904,7 +933,10 @@ async fn remote_control_http_mode_enrolls_before_connecting() { let expected_server_name = gethostname().to_string_lossy().trim().to_string(); let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( - remote_control_url, + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, Some(remote_control_state_runtime(&codex_home).await), remote_control_auth_manager(), transport_event_tx, @@ -929,6 +961,12 @@ async fn remote_control_http_mode_enrolls_before_connecting() { enroll_request.headers.get(REMOTE_CONTROL_ACCOUNT_ID_HEADER), Some(&"account_id".to_string()) ); + assert_eq!( + enroll_request + .headers + .get(REMOTE_CONTROL_INSTALLATION_ID_HEADER), + Some(&TEST_INSTALLATION_ID.to_string()) + ); assert_eq!( serde_json::from_str::(&enroll_request.body) .expect("enroll body should deserialize"), @@ -937,6 +975,7 @@ async fn remote_control_http_mode_enrolls_before_connecting() { "os": std::env::consts::OS, "arch": std::env::consts::ARCH, "app_server_version": env!("CARGO_PKG_VERSION"), + "installation_id": TEST_INSTALLATION_ID, }) ); respond_with_json( @@ -967,6 +1006,12 @@ async fn remote_control_http_mode_enrolls_before_connecting() { .get(REMOTE_CONTROL_ACCOUNT_ID_HEADER), Some(&"account_id".to_string()) ); + assert_eq!( + handshake_request + .headers + .get(REMOTE_CONTROL_INSTALLATION_ID_HEADER), + Some(&TEST_INSTALLATION_ID.to_string()) + ); assert_eq!( handshake_request.headers.get("x-codex-server-id"), Some(&"srv_e_test".to_string()) @@ -1128,7 +1173,10 @@ async fn remote_control_http_mode_reuses_persisted_enrollment_before_reenrolling mpsc::channel::(CHANNEL_CAPACITY); let shutdown_token = CancellationToken::new(); let (remote_task, _remote_handle) = start_remote_control( - remote_control_url, + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, Some(state_db.clone()), remote_control_auth_manager_with_home(&codex_home), transport_event_tx, @@ -1196,7 +1244,10 @@ async fn remote_control_stdio_mode_waits_for_client_name_before_connecting() { let (app_server_client_name_tx, app_server_client_name_rx) = oneshot::channel::(); let shutdown_token = CancellationToken::new(); let (remote_task, _remote_handle) = start_remote_control( - remote_control_url, + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, Some(state_db.clone()), remote_control_auth_manager_with_home(&codex_home), transport_event_tx, @@ -1255,7 +1306,10 @@ async fn remote_control_waits_for_account_id_before_enrolling() { mpsc::channel::(CHANNEL_CAPACITY); let shutdown_token = CancellationToken::new(); let (remote_task, _remote_handle) = start_remote_control( - remote_control_url, + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, Some(state_db.clone()), auth_manager, transport_event_tx, @@ -1338,7 +1392,10 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404() mpsc::channel::(CHANNEL_CAPACITY); let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( - remote_control_url, + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, Some(state_db.clone()), remote_control_auth_manager_with_home(&codex_home), transport_event_tx, diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index f7b49b72ec..472639bc68 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -55,6 +55,7 @@ use tracing::warn; pub(super) const REMOTE_CONTROL_PROTOCOL_VERSION: &str = "3"; pub(super) const REMOTE_CONTROL_ACCOUNT_ID_HEADER: &str = "chatgpt-account-id"; +pub(super) const REMOTE_CONTROL_INSTALLATION_ID_HEADER: &str = "x-codex-installation-id"; const REMOTE_CONTROL_SUBSCRIBE_CURSOR_HEADER: &str = "x-codex-subscribe-cursor"; const REMOTE_CONTROL_WEBSOCKET_PING_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10); @@ -214,6 +215,7 @@ impl WebsocketState { pub(crate) struct RemoteControlWebsocket { remote_control_url: String, + installation_id: String, remote_control_target: Option, state_db: Option>, auth_manager: Arc, @@ -229,6 +231,12 @@ pub(crate) struct RemoteControlWebsocket { enabled_rx: watch::Receiver, } +pub(crate) struct RemoteControlWebsocketConfig { + pub(crate) remote_control_url: String, + pub(crate) installation_id: String, + pub(crate) remote_control_target: Option, +} + enum ConnectOutcome { Connected(Box>>), Disabled, @@ -254,6 +262,7 @@ impl RemoteControlStatusPublisher { self.tx.send_if_modified(|status| { let next_status = RemoteControlStatusChangedNotification { status: connection_status, + installation_id: status.installation_id.clone(), environment_id: if connection_status == RemoteControlConnectionStatus::Disabled { None } else { @@ -276,6 +285,7 @@ impl RemoteControlStatusPublisher { } let next_status = RemoteControlStatusChangedNotification { status: status.status, + installation_id: status.installation_id.clone(), environment_id, }; if *status == next_status { @@ -290,14 +300,14 @@ impl RemoteControlStatusPublisher { #[derive(Clone, Copy)] pub(super) struct RemoteControlConnectOptions<'a> { + installation_id: &'a str, subscribe_cursor: Option<&'a str>, app_server_client_name: Option<&'a str>, } impl RemoteControlWebsocket { pub(crate) fn new( - remote_control_url: String, - remote_control_target: Option, + config: RemoteControlWebsocketConfig, state_db: Option>, auth_manager: Arc, channels: RemoteControlChannels, @@ -315,8 +325,9 @@ impl RemoteControlWebsocket { let auth_recovery = auth_manager.unauthorized_recovery(); Self { - remote_control_url, - remote_control_target, + remote_control_url: config.remote_control_url, + installation_id: config.installation_id, + remote_control_target: config.remote_control_target, state_db, auth_manager, status_publisher: channels.status_publisher, @@ -442,6 +453,7 @@ impl RemoteControlWebsocket { loop { let subscribe_cursor = self.state.lock().await.subscribe_cursor.clone(); let connect_options = RemoteControlConnectOptions { + installation_id: &self.installation_id, subscribe_cursor: subscribe_cursor.as_deref(), app_server_client_name, }; @@ -918,6 +930,7 @@ fn build_remote_control_websocket_request( websocket_url: &str, enrollment: &RemoteControlEnrollment, auth: &RemoteControlConnectionAuth, + installation_id: &str, subscribe_cursor: Option<&str>, ) -> io::Result> { let mut request = websocket_url.into_client_request().map_err(|err| { @@ -942,6 +955,11 @@ fn build_remote_control_websocket_request( auth.auth_provider.add_auth_headers(&mut auth_headers); headers.extend(auth_headers); set_remote_control_header(headers, REMOTE_CONTROL_ACCOUNT_ID_HEADER, &auth.account_id)?; + set_remote_control_header( + headers, + REMOTE_CONTROL_INSTALLATION_ID_HEADER, + installation_id, + )?; if let Some(subscribe_cursor) = subscribe_cursor { set_remote_control_header( headers, @@ -1066,7 +1084,12 @@ pub(super) async fn connect_remote_control_websocket( "creating new remote control enrollment: websocket_url={}, enroll_url={}, account_id={}", remote_control_target.websocket_url, remote_control_target.enroll_url, auth.account_id ); - let new_enrollment = match enroll_remote_control_server(remote_control_target, &auth).await + let new_enrollment = match enroll_remote_control_server( + remote_control_target, + &auth, + connect_options.installation_id, + ) + .await { Ok(new_enrollment) => new_enrollment, Err(err) @@ -1110,6 +1133,7 @@ pub(super) async fn connect_remote_control_websocket( &remote_control_target.websocket_url, enrollment_ref, &auth, + connect_options.installation_id, connect_options.subscribe_cursor, )?; @@ -1247,6 +1271,7 @@ mod tests { const TEST_HTTP_ACCEPT_TIMEOUT: Duration = Duration::from_secs(30); #[cfg(not(windows))] const TEST_HTTP_ACCEPT_TIMEOUT: Duration = Duration::from_secs(5); + const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; fn remote_control_status_channel() -> ( RemoteControlStatusPublisher, @@ -1254,6 +1279,7 @@ mod tests { ) { let (status_tx, status_rx) = watch::channel(RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, }); (RemoteControlStatusPublisher::new(status_tx), status_rx) @@ -1359,6 +1385,7 @@ mod tests { &mut auth_recovery, &mut enrollment, RemoteControlConnectOptions { + installation_id: TEST_INSTALLATION_ID, subscribe_cursor: None, app_server_client_name: None, }, @@ -1376,6 +1403,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_test".to_string()), } ); @@ -1435,6 +1463,7 @@ mod tests { &mut auth_recovery, &mut enrollment, RemoteControlConnectOptions { + installation_id: TEST_INSTALLATION_ID, subscribe_cursor: None, app_server_client_name: None, }, @@ -1448,6 +1477,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_test".to_string()), } ); @@ -1515,6 +1545,7 @@ mod tests { &mut auth_recovery, &mut enrollment, RemoteControlConnectOptions { + installation_id: TEST_INSTALLATION_ID, subscribe_cursor: None, app_server_client_name: None, }, @@ -1567,6 +1598,7 @@ mod tests { &mut auth_recovery, &mut enrollment, RemoteControlConnectOptions { + installation_id: TEST_INSTALLATION_ID, subscribe_cursor: None, app_server_client_name: None, }, @@ -1614,6 +1646,7 @@ mod tests { &mut auth_recovery, &mut enrollment, RemoteControlConnectOptions { + installation_id: TEST_INSTALLATION_ID, subscribe_cursor: None, app_server_client_name: None, }, @@ -1632,6 +1665,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } ); @@ -1656,8 +1690,11 @@ mod tests { let shutdown_token = shutdown_token.clone(); async move { RemoteControlWebsocket::new( - remote_control_url, - Some(remote_control_target), + RemoteControlWebsocketConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + remote_control_target: Some(remote_control_target), + }, /*state_db*/ None, remote_control_auth_manager(), RemoteControlChannels { @@ -1701,6 +1738,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_first".to_string()), } ); @@ -1721,6 +1759,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connected, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_first".to_string()), } ); @@ -1734,6 +1773,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connected, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } ); @@ -1748,6 +1788,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Disabled, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } ); diff --git a/codex-rs/app-server-transport/src/transport/websocket.rs b/codex-rs/app-server-transport/src/transport/websocket.rs index 627197c29b..177e378906 100644 --- a/codex-rs/app-server-transport/src/transport/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/websocket.rs @@ -3,7 +3,7 @@ use super::ConnectionOrigin; use super::TransportEvent; use super::auth::WebsocketAuthPolicy; use super::auth::authorize_upgrade; -use super::auth::should_warn_about_unauthenticated_non_loopback_listener; +use super::auth::is_unauthenticated_non_loopback_listener; use super::forward_incoming_message; use super::next_connection_id; use super::serialize_outgoing_message; @@ -72,9 +72,7 @@ fn print_websocket_startup_banner(addr: SocketAddr) { " {note_label} binds localhost only (use SSH port-forwarding for remote access)" ); } else { - eprintln!( - " {note_label} websocket auth is opt-in in this build; configure `--ws-auth ...` before real remote use" - ); + eprintln!(" {note_label} websocket auth is required for non-localhost listeners"); } } @@ -134,11 +132,13 @@ pub async fn start_websocket_acceptor( shutdown_token: CancellationToken, auth_policy: WebsocketAuthPolicy, ) -> IoResult> { - if should_warn_about_unauthenticated_non_loopback_listener(bind_address, &auth_policy) { - warn!( - %bind_address, - "starting non-loopback websocket listener without auth; websocket auth is opt-in for now and will become the default in a future release" - ); + if is_unauthenticated_non_loopback_listener(bind_address, &auth_policy) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "refusing to start non-loopback websocket listener {bind_address} without auth; configure `--ws-auth capability-token` or `--ws-auth signed-bearer-token`" + ), + )); } let listener = TcpListener::bind(bind_address).await?; let local_addr = listener.local_addr()?; diff --git a/codex-rs/app-server/BUILD.bazel b/codex-rs/app-server/BUILD.bazel index b7ff5b1695..6765141bdc 100644 --- a/codex-rs/app-server/BUILD.bazel +++ b/codex-rs/app-server/BUILD.bazel @@ -14,5 +14,8 @@ codex_rust_crate( "app-server-all-test": 16, "app-server-unit-tests": 8, }, + extra_binaries = [ + "//codex-rs/bwrap:bwrap", + ], test_tags = ["no-sandbox"], ) diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 6d201bdee3..ecc8d1c25a 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -15,6 +15,7 @@ path = "src/bin/notify_capture.rs" [lib] name = "codex_app_server" path = "src/lib.rs" +doctest = false [lints] workspace = true @@ -35,12 +36,15 @@ codex-cloud-requirements = { workspace = true } codex-config = { workspace = true } codex-core = { workspace = true } codex-core-plugins = { workspace = true } -codex-device-key = { workspace = true } codex-exec-server = { workspace = true } +codex-extension-api = { workspace = true } codex-external-agent-migration = { workspace = true } codex-external-agent-sessions = { workspace = true } codex-features = { workspace = true } +codex-guardian = { workspace = true } +codex-git-attribution = { workspace = true } codex-git-utils = { workspace = true } +codex-file-watcher = { workspace = true } codex-hooks = { workspace = true } codex-otel = { workspace = true } codex-plugin = { workspace = true } @@ -72,7 +76,6 @@ clap = { workspace = true, features = ["derive"] } futures = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -sha2 = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } time = { workspace = true } @@ -88,20 +91,19 @@ tokio = { workspace = true, features = [ tokio-util = { workspace = true } tracing = { workspace = true, features = ["log"] } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt", "json"] } -url = { workspace = true } uuid = { workspace = true, features = ["serde", "v7"] } [dev-dependencies] app_test_support = { workspace = true } -base64 = { workspace = true } axum = { workspace = true, default-features = false, features = [ "http1", "json", "tokio", ] } -core_test_support = { workspace = true } +base64 = { workspace = true } codex-model-provider-info = { workspace = true } codex-utils-cargo-bin = { workspace = true } +core_test_support = { workspace = true } flate2 = { workspace = true } hmac = { workspace = true } opentelemetry = { workspace = true } @@ -114,8 +116,10 @@ rmcp = { workspace = true, default-features = false, features = [ "transport-streamable-http-server", ] } serial_test = { workspace = true } +sha2 = { workspace = true } +shlex = { workspace = true } tar = { workspace = true } tokio-tungstenite = { workspace = true } tracing-opentelemetry = { workspace = true } +url = { workspace = true } wiremock = { workspace = true } -shlex = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index dab47ec3a2..788fc9e7c3 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -41,18 +41,6 @@ opens exactly one raw stream connection to `$CODEX_HOME/app-server-control/app-s by default, or to `--sock PATH` when provided, and proxies bytes between that socket and stdin/stdout. The proxied stream carries the websocket HTTP Upgrade handshake followed by websocket frames. -Security note: - -- Loopback websocket listeners (`ws://127.0.0.1:PORT`) remain appropriate for localhost and SSH port-forwarding workflows. -- Non-loopback websocket listeners currently allow unauthenticated connections by default during rollout. If you expose one remotely, configure websocket auth explicitly now. -- Supported auth modes are app-server flags: - - `--ws-auth capability-token --ws-token-file /absolute/path` - - `--ws-auth capability-token --ws-token-sha256 HEX` - - `--ws-auth signed-bearer-token --ws-shared-secret-file /absolute/path` for HMAC-signed JWT/JWS bearer tokens, with optional `--ws-issuer`, `--ws-audience`, `--ws-max-clock-skew-seconds` -- Clients present the credential as `Authorization: Bearer ` during the websocket handshake. Auth is enforced before JSON-RPC `initialize`. -- When starting `codex app-server` manually, prefer `--ws-token-file` over passing raw bearer tokens on the command line. Store a high-entropy token in a file readable only by your user, then have your client present that token in the websocket `Authorization` header. -- `--ws-token-sha256` is intended for clients that keep the raw token in a separate local secret store and only need the server to know the SHA-256 verifier. The hash may appear in process listings, but it is not sufficient to authenticate; clients still need the original raw token. Only use this mode with randomly generated high-entropy tokens, not passwords or other guessable values. - Tracing/log output: - `RUST_LOG` controls log filtering/verbosity. @@ -149,11 +137,12 @@ Example with notification opt-out: - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. -- `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `nextCursor`, and `backwardsCursor`. +- `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`. +- `thread/turns/items/list` — experimental; reserved for paging full items for one turn. The API shape is present, but app-server currently returns an unsupported-method JSON-RPC error. - `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`. - `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success. - `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success. -- `thread/goal/set` — create, replace, or update the single persisted goal for a materialized thread; returns the current goal and emits `thread/goal/updated`. Supplying a new `objective` replaces the goal and resets usage accounting. Supplying the current non-terminal objective or omitting `objective` updates the existing goal’s status and/or token budget while preserving usage. +- `thread/goal/set` — create or update the single persisted goal for a materialized thread; returns the current goal and emits `thread/goal/updated`. - `thread/goal/get` — fetch the current persisted goal for a materialized thread; returns `goal: null` when no goal exists. - `thread/goal/clear` — clear the current persisted goal for a materialized thread; returns whether a goal was removed and emits `thread/goal/cleared` when state changes. - `thread/goal/updated` — notification emitted whenever a thread goal changes; includes the full current goal. @@ -181,6 +170,12 @@ Example with notification opt-out: - `command/exec/resize` — resize a running PTY-backed `command/exec` session by `processId`; returns `{}`. - `command/exec/terminate` — terminate a running `command/exec` session by `processId`; returns `{}`. - `command/exec/outputDelta` — notification emitted for base64-encoded stdout/stderr chunks from a streaming `command/exec` session. +- `process/spawn` — experimental; spawn a standalone process without the Codex sandbox on the host where the app server is running; returns after the process starts and emits `process/outputDelta` and `process/exited` notifications. +- `process/writeStdin` — experimental; write base64-decoded stdin bytes to a running `process/spawn` session or close stdin; returns `{}`. +- `process/resizePty` — experimental; resize a running PTY-backed `process/spawn` session by `processHandle`; returns `{}`. +- `process/kill` — experimental; terminate a running `process/spawn` session by `processHandle`; returns `{}`. +- `process/outputDelta` — experimental; notification emitted for base64-encoded stdout/stderr chunks from a streaming `process/spawn` session. +- `process/exited` — experimental; notification emitted when a `process/spawn` session exits. - `fs/readFile` — read an absolute file path and return `{ dataBase64 }`. - `fs/writeFile` — write an absolute file path from base64-encoded `{ dataBase64 }`; returns `{}`. - `fs/createDirectory` — create an absolute directory path; `recursive` defaults to `true`. @@ -195,6 +190,7 @@ Example with notification opt-out: - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `memories`, `plugins`, `remote_control`, `tool_search`, `tool_suggest`, `tool_call_mcp_elicitation`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. +- `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). - `hooks/list` — list discovered hooks for one or more `cwd` values. @@ -202,13 +198,10 @@ Example with notification opt-out: - `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists. - `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors. - `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**). -- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**). +- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**). - `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle. - `skills/changed` — notification emitted when watched local skill files change. - `app/list` — list available apps. -- `device/key/create` — create or load a controller-local device signing key for an account/client binding. This local-key API is available only over local transports such as stdio and in-process; remote transports reject it. Hardware-backed providers are the target protection class; an OS-protected non-extractable fallback is allowed only with `protectionPolicy: "allow_os_protected_nonextractable"` and returns the reported `protectionClass`. -- `device/key/public` — return a device key's SPKI DER public key as base64 plus its `algorithm` and `protectionClass`. -- `device/key/sign` — sign one of the accepted structured payload variants with a controller-local device key. The only accepted payload today is `remoteControlClientConnection`, which binds a server-issued `/client` websocket challenge to the enrolled controller device without signing the bearer token itself; this is intentionally not an arbitrary-byte signing API. - `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot. - `skills/config/write` — write user-level skill config by name or absolute path. - `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**). @@ -226,7 +219,7 @@ Example with notification opt-out: - `externalAgentConfig/import` — apply selected external-agent migration items by passing explicit `migrationItems` with `cwd` (`null` for home) and any plugin/session `details` returned by detect. When a request includes migration items, the server emits `externalAgentConfig/import/completed` once after the full import finishes (immediately after the response when everything completed synchronously, or after background imports finish). - `config/value/write` — write a single config key/value to the user's config.toml on disk. - `config/batchWrite` — apply multiple config edits atomically to the user's config.toml on disk, with optional `reloadUserConfig: true` to hot-reload loaded threads. -- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. +- `configRequirements/read` — fetch loaded requirements constraints from `requirements.toml` and/or MDM (or `null` if none are configured), including allow-lists (`allowedApprovalPolicies`, `allowedSandboxModes`, `allowedWebSearchModes`), lifecycle hook lockdown (`allowManagedHooksOnly`), pinned feature values (`featureRequirements`), managed lifecycle hooks (`hooks`), `enforceResidency`, and `network` constraints such as canonical domain/socket permissions plus `managedAllowedDomainsOnly` and `dangerFullAccessDenylistOnly`. ### Example: Start or resume a thread @@ -297,18 +290,16 @@ Example: { "id": 12, "result": { "thread": { "id": "thr_123", "turns": [], … } } } ``` -To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. When the source history includes persisted token usage, the server also emits `thread/tokenUsage/updated` for the new thread immediately after the response. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only: +To branch from a stored session, call `thread/fork` with the `thread.id`. This creates a new thread id and emits a `thread/started` notification for it. The returned `thread.sessionId` identifies the current live session tree root. Root threads use their own `thread.id` as `thread.sessionId`; stored threads that are not loaded also report their own `thread.id`, because resuming one makes it the root of a new live session tree. When the source history includes persisted token usage, the server also emits `thread/tokenUsage/updated` for the new thread immediately after the response. If the source thread is actively running, the fork snapshots it as if the current turn had been interrupted first. Pass `ephemeral: true` when the fork should stay in-memory only: ```json { "method": "thread/fork", "id": 12, "params": { "threadId": "thr_123", "ephemeral": true } } -{ "id": 12, "result": { "thread": { "id": "thr_456", … } } } +{ "id": 12, "result": { "thread": { "id": "thr_456", "sessionId": "thr_456", … } } } { "method": "thread/started", "params": { "thread": { … } } } ``` Like `thread/resume`, experimental clients can pass `excludeTurns: true` to `thread/fork` to return only thread metadata in `thread.turns` and page history with `thread/turns/list`. In that mode the server skips replaying restored `thread/tokenUsage/updated`, which keeps the fork path from rebuilding turns just to attribute historical usage. -Experimental API: `thread/start`, `thread/resume`, and `thread/fork` accept `persistExtendedHistory: true` to persist a richer subset of ThreadItems for non-lossy history when calling `thread/read`, `thread/resume`, and `thread/fork` later. This does not backfill events that were not persisted previously. - ### Example: List threads (with pagination & filters) `thread/list` lets you render a history UI. Results default to `createdAt` (newest first) descending. Pass any combination of: @@ -403,7 +394,7 @@ Later, after the idle unload timeout: ### Example: Read a thread -Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want the full rollout history loaded into `thread.turns`. The returned thread includes `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available. +Use `thread/read` to fetch a stored thread by id without resuming it. Pass `includeTurns` when you want thread history loaded into `thread.turns`. The returned thread includes `agentNickname` and `agentRole` for AgentControl-spawned thread sub-agents when available. ```json { "method": "thread/read", "id": 22, "params": { "threadId": "thr_123" } } @@ -423,11 +414,14 @@ Use `thread/read` to fetch a stored thread by id without resuming it. Pass `incl Use `thread/turns/list` with `capabilities.experimentalApi = true` to page a stored thread’s turn history without resuming it. By default, results are sorted descending so clients can start at the present and fetch older turns with `nextCursor`. The response also includes `backwardsCursor`; pass it as `cursor` on a later request with `sortDirection: "asc"` to fetch turns newer than the first item from the earlier page. +Every returned `Turn` includes `itemsView`, which tells clients whether the `items` array was omitted intentionally (`notLoaded`), contains only summary items (`summary`), or contains every item available from persisted app-server history (`full`). Pass `itemsView` to choose the returned detail level; omitted `itemsView` defaults to `"summary"`. + ```json { "method": "thread/turns/list", "id": 24, "params": { "threadId": "thr_123", "limit": 50, - "sortDirection": "desc" + "sortDirection": "desc", + "itemsView": "summary" } } { "id": 24, "result": { "data": [ ... ], @@ -436,6 +430,19 @@ Use `thread/turns/list` with `capabilities.experimentalApi = true` to page a sto } } ``` +`thread/turns/items/list` is the planned hydration API for fetching full items for one turn: + +```json +{ "method": "thread/turns/items/list", "id": 25, "params": { + "threadId": "thr_123", + "turnId": "turn_456", + "limit": 100, + "sortDirection": "asc" +} } +``` + +This method currently returns JSON-RPC `-32601` with message `thread/turns/items/list is not supported yet`. + ### Example: Update stored thread metadata Use `thread/metadata/update` to patch sqlite-backed metadata for a thread without resuming it. Today this supports persisted `gitInfo`; omitted fields are left unchanged, while explicit `null` clears a stored value. @@ -483,7 +490,7 @@ Experimental: use `memory/reset` to clear local memory artifacts and sqlite-back ### Example: Set and update a thread goal -Use `thread/goal/set` with an `objective` to create or replace the current goal for a materialized thread. Supplying a new objective resets `tokensUsed`, `timeUsedSeconds`, and `createdAt`. Supplying the current non-terminal objective, or omitting `objective`, updates the existing goal’s status or token budget while preserving usage history. Clients can set `budgetLimited` when they stop because a token budget is exhausted or nearly exhausted; the system also sets it when accounting crosses a configured token budget. +Use `thread/goal/set` to create or update the current goal for a materialized thread. Clients can set `budgetLimited` when they stop because a token budget is exhausted or nearly exhausted; the system also sets it when accounting crosses a configured token budget. ```json { "method": "thread/goal/set", "id": 27, "params": { @@ -932,6 +939,7 @@ Run a standalone command (argv vector) in the server’s sandbox without creatin } } ``` +- Prefer using `process/spawn` when you want an explicitly unsandboxed process execution API with immediate spawn acknowledgement, handle-based control, output notifications, and an exit notification. - For clients that are already sandboxed externally, set the legacy `sandboxPolicy` to `{"type":"externalSandbox","networkAccess":"enabled"}` (or omit `networkAccess` to keep it restricted). Codex will not enforce its own sandbox in this mode; it tells the model it has full file-system access and passes the `networkAccess` state through `environment_context`. Notes: @@ -1003,6 +1011,83 @@ Streaming stdin/stdout uses base64 so PTY sessions can carry arbitrary bytes: - `command/exec.params.env` overrides the server-computed environment per key; set a key to `null` to unset an inherited variable. - `command/exec/resize` is only supported for PTY-backed `command/exec` sessions. +### Example: Process lifecycle execution + +Use `process/spawn` to start a standalone argv-based process without the Codex sandbox on the host where the app server is running. The `process/*` API is experimental and requires `initialize.params.capabilities.experimentalApi: true`. The spawn response means the process has started and the `processHandle` is registered; completion is reported later through `process/exited`. + +```json +{ "method": "process/spawn", "id": 40, "params": { + "command": ["cargo", "check"], + "processHandle": "cargo-check-1", + "cwd": "/Users/me/project", // required absolute path + "env": { "RUST_LOG": null }, // optional; override or unset app-server env vars + "outputBytesCap": 1048576, // optional; omit for default, null disables + "timeoutMs": 10000 // optional; omit for default, null disables +} } +{ "id": 40, "result": {} } +{ "method": "process/exited", "params": { + "processHandle": "cargo-check-1", + "exitCode": 0, + "stdout": "...", + "stdoutCapReached": false, + "stderr": "", + "stderrCapReached": false +} } +``` + +For interactive or streaming processes, set `tty: true` or `streamStdoutStderr: true` and route output notifications by `processHandle`: + +```json +{ "method": "process/spawn", "id": 41, "params": { + "command": ["bash", "-i"], + "processHandle": "bash-1", + "cwd": "/Users/me/project", + "tty": true, + "size": { "rows": 40, "cols": 120 }, + "outputBytesCap": null, + "timeoutMs": null +} } +{ "id": 41, "result": {} } +{ "method": "process/outputDelta", "params": { + "processHandle": "bash-1", + "stream": "stdout", + "deltaBase64": "YmFzaC00LjQkIA==", + "capReached": false +} } +{ "method": "process/writeStdin", "id": 42, "params": { + "processHandle": "bash-1", + "deltaBase64": "cHdkCg==" +} } +{ "id": 42, "result": {} } +{ "method": "process/resizePty", "id": 43, "params": { + "processHandle": "bash-1", + "size": { "rows": 48, "cols": 160 } +} } +{ "id": 43, "result": {} } +{ "method": "process/kill", "id": 44, "params": { + "processHandle": "bash-1" +} } +{ "id": 44, "result": {} } +{ "method": "process/exited", "params": { + "processHandle": "bash-1", + "exitCode": 137, + "stdout": "", + "stdoutCapReached": false, + "stderr": "", + "stderrCapReached": false +} } +``` + +- Empty `command` arrays and empty `processHandle` strings are rejected. +- `cwd` is required and must be absolute. +- `process/spawn` is intentionally unsandboxed and does not define sandbox-selection fields such as `sandboxPolicy` or `permissionProfile`. +- Duplicate active `processHandle` values are rejected on the same connection; the same handle can be reused after the prior process exits. +- `tty: true` implies PTY mode plus `streamStdin: true` and `streamStdoutStderr: true`. +- `process/writeStdin` accepts either `deltaBase64`, `closeStdin`, or both. +- When omitted, `timeoutMs` and `outputBytesCap` fall back to server defaults. Set either field to `null` to disable that limit for terminal-style sessions. +- `outputBytesCap` applies independently to `stdout` and `stderr`; `process/exited.stdoutCapReached` and `stderrCapReached` report whether each stream reached the cap. Streamed bytes are not duplicated into `process/exited`. +- `process/outputDelta` and `process/exited` notifications are connection-scoped. If the originating connection closes, the server terminates the process. + ### Example: Filesystem utilities These methods operate on absolute paths on the host filesystem and cover reading, writing, directory traversal, copying, removal, and change notifications. @@ -1241,6 +1326,10 @@ UI guidance for IDEs: surface an approval dialog as soon as the request arrives. When the client responds to `item/tool/requestUserInput`, the server emits `serverRequest/resolved` with `{ threadId, requestId }`. If the pending request is cleared by turn start, turn completion, or turn interruption before the client answers, the server emits the same notification for that cleanup. +### Attestation generation + +Desktop hosts that provide upstream attestation should set `capabilities.requestAttestation` during `initialize` and handle the server-initiated `attestation/generate` request. App-server issues it just in time before ChatGPT Codex requests that forward `x-oai-attestation`; the client responds with `{ "token": "v1." }`, where `token` is an opaque client-owned value. When app-server receives a client response, it forwards a consistent outer envelope such as `{ "v": 1, "s": 0, "t": "v1." }`, where `t` contains the client token unchanged. If app-server attempts attestation but fails within its own boundary, it sends the same envelope shape with an app-server status code and without `t` (`1 = timeout`, `2 = request failed`, `3 = request canceled`, `4 = malformed response`). If no initialized client opted into attestation, app-server omits `x-oai-attestation` for that upstream request. + ### MCP server elicitations MCP servers can interrupt a turn and ask the client for structured input via `mcpServer/elicitation/request`. @@ -1309,6 +1398,12 @@ If the session approval policy uses `Granular` with `request_permissions: false` `dynamicTools` on `thread/start` and the corresponding `item/tool/call` request/response flow are experimental APIs. To enable them, set `initialize.params.capabilities.experimentalApi = true`. +Dynamic tool identifiers follow the same constraints as Responses function tools: + +- `name` must match `^[a-zA-Z0-9_-]+$` and be between 1 and 128 characters. +- `namespace`, when present, must match `^[a-zA-Z0-9_-]+$` and be between 1 and 64 characters. +- `namespace` must not collide with reserved Responses runtime namespaces such as `functions`, `multi_tool_use`, `file_search`, `web`, `browser`, `image_gen`, `computer`, `container`, `terminal`, `python`, `python_user_visible`, `api_tool`, `tool_search`, or `submodel_delegator`. + Each dynamic tool may set `deferLoading`. When omitted, it defaults to `false`. Set it to `true` to keep the tool registered and callable by runtime features such as `code_mode`, while excluding it from the model-facing tool list sent on ordinary turns. When `tool_search` is available, deferred dynamic tools are searchable and can be exposed by a matching search result. When a dynamic tool is invoked during a turn, the server sends an `item/tool/call` JSON-RPC request to the client: @@ -1383,21 +1478,13 @@ $skill-creator Add a new skill for triaging flaky CI and include step-by-step us ``` Use `skills/list` to fetch the available skills (optionally scoped by `cwds`, with `forceReload`). -You can also add `perCwdExtraUserRoots` to scan additional absolute paths as `user` scope for specific `cwd` entries. -Entries whose `cwd` is not present in `cwds` are ignored. `skills/list` might reuse a cached skills result per `cwd`; setting `forceReload` to `true` refreshes the result from disk. The server also emits `skills/changed` notifications when watched local skill files change. Treat this as an invalidation signal and re-run `skills/list` with your current params when needed. ```json { "method": "skills/list", "id": 25, "params": { "cwds": ["/Users/me/project", "/Users/me/other-project"], - "forceReload": true, - "perCwdExtraUserRoots": [ - { - "cwd": "/Users/me/project", - "extraUserRoots": ["/Users/me/shared-skills"] - } - ] + "forceReload": true } } { "id": 25, "result": { "data": [{ @@ -1457,7 +1544,13 @@ To enable or disable a skill by name: } ``` -Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Each entry is evaluated using that `cwd`'s effective config, so feature gating and discovered config layers can differ across entries in the same request. Disabled hooks are still returned with `"enabled": false` so clients can render and re-enable them. Hook state is stored under `hooks.state`; clients should treat hooks from managed sources as non-configurable, and user config entries for those keys are ignored during loading. Hook keys combine the source identity with a trailing event/group/handler selector that is currently positional. +Use `hooks/list` to fetch discovered hooks for one or more `cwds`. Each result is evaluated with that `cwd`'s effective config, so feature gates and discovered config layers can differ within a single response. + +For linked Git worktrees, project hook declarations come from the matching `.codex/` folders in the root checkout rather than from divergent hook declarations stored only in the linked worktree. This keeps each repo on one authoritative project-hook definition and one trust state. + +Hooks are returned even when disabled so clients can render and re-enable them. User-controlled state lives under `hooks.state`. Managed hooks are non-configurable, and user entries for managed hook keys are ignored during loading. + +For unmanaged hooks, `currentHash` and `trustStatus` describe whether the current definition is first-seen, approved, or changed since approval. Only trusted unmanaged hooks become runnable. Hook keys combine the source identity with a trailing event/group/handler selector that is currently positional. ```json { @@ -1488,7 +1581,9 @@ Use `hooks/list` to fetch the discovered hooks for one or more `cwds`. Each entr "source": "user", "pluginId": null, "displayOrder": 0, - "enabled": true + "enabled": true, + "currentHash": "sha256:...", + "trustStatus": "untrusted" }], "warnings": [], "errors": [] diff --git a/codex-rs/app-server/src/attestation.rs b/codex-rs/app-server/src/attestation.rs new file mode 100644 index 0000000000..17bb10c38c --- /dev/null +++ b/codex-rs/app-server/src/attestation.rs @@ -0,0 +1,217 @@ +use std::sync::Arc; + +use axum::http::HeaderValue; +use codex_app_server_protocol::AttestationGenerateParams; +use codex_app_server_protocol::AttestationGenerateResponse; +use codex_app_server_protocol::ServerRequestPayload; +use codex_core::AttestationContext; +use codex_core::AttestationProvider; +use codex_core::GenerateAttestationFuture; +use serde::Serialize; +use tokio::time::Duration; +use tokio::time::timeout; +use tracing::warn; + +use crate::outgoing_message::OutgoingMessageSender; +use crate::thread_state::ThreadStateManager; + +const ATTESTATION_GENERATE_TIMEOUT: Duration = Duration::from_millis(100); + +pub(crate) fn app_server_attestation_provider( + outgoing: Arc, + thread_state_manager: ThreadStateManager, +) -> Arc { + Arc::new(AppServerAttestationProvider { + outgoing, + thread_state_manager, + }) +} + +struct AppServerAttestationProvider { + outgoing: Arc, + thread_state_manager: ThreadStateManager, +} + +impl std::fmt::Debug for AppServerAttestationProvider { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("AppServerAttestationProvider") + .finish() + } +} + +impl AttestationProvider for AppServerAttestationProvider { + fn header_for_request(&self, context: AttestationContext) -> GenerateAttestationFuture<'_> { + let outgoing = self.outgoing.clone(); + let thread_state_manager = self.thread_state_manager.clone(); + Box::pin(async move { + request_attestation_header_value_with_timeout( + outgoing, + thread_state_manager, + context.thread_id, + ATTESTATION_GENERATE_TIMEOUT, + ) + .await + .and_then(|value| HeaderValue::from_bytes(value.as_bytes()).ok()) + }) + } +} + +async fn request_attestation_header_value_with_timeout( + outgoing: Arc, + thread_state_manager: ThreadStateManager, + thread_id: codex_protocol::ThreadId, + timeout_duration: Duration, +) -> Option { + let connection_id = thread_state_manager + .first_attestation_capable_connection_for_thread(thread_id) + .await?; + + let connection_ids = [connection_id]; + let (request_id, rx) = outgoing + .send_request_to_connections( + Some(&connection_ids), + ServerRequestPayload::AttestationGenerate(AttestationGenerateParams {}), + /*thread_id*/ None, + ) + .await; + + let result = match timeout(timeout_duration, rx).await { + Ok(Ok(Ok(result))) => result, + Ok(Ok(Err(err))) => { + warn!( + code = err.code, + message = %err.message, + "attestation generation request failed" + ); + return app_server_attestation_header_value( + AppServerAttestationStatus::RequestFailed, + /*token*/ None, + ); + } + Ok(Err(err)) => { + warn!("attestation generation request canceled: {err}"); + return app_server_attestation_header_value( + AppServerAttestationStatus::RequestCanceled, + /*token*/ None, + ); + } + Err(_) => { + let _canceled = outgoing.cancel_request(&request_id).await; + warn!( + timeout_seconds = timeout_duration.as_secs(), + "attestation generation request timed out" + ); + return app_server_attestation_header_value( + AppServerAttestationStatus::Timeout, + /*token*/ None, + ); + } + }; + + match serde_json::from_value::(result) { + Ok(response) => app_server_attestation_header_value( + AppServerAttestationStatus::Ok, + Some(&response.token), + ), + Err(err) => { + warn!("failed to deserialize attestation generation response: {err}"); + app_server_attestation_header_value( + AppServerAttestationStatus::MalformedResponse, + /*token*/ None, + ) + } + } +} + +#[derive(Clone, Copy)] +enum AppServerAttestationStatus { + Ok, + Timeout, + RequestFailed, + RequestCanceled, + MalformedResponse, +} + +impl AppServerAttestationStatus { + const fn code(self) -> u8 { + match self { + Self::Ok => 0, + Self::Timeout => 1, + Self::RequestFailed => 2, + Self::RequestCanceled => 3, + Self::MalformedResponse => 4, + } + } +} + +#[derive(Serialize)] +struct AppServerAttestationEnvelope<'a> { + v: u8, + s: u8, + #[serde(skip_serializing_if = "Option::is_none")] + t: Option<&'a str>, +} + +fn app_server_attestation_header_value( + status: AppServerAttestationStatus, + token: Option<&str>, +) -> Option { + serde_json::to_string(&AppServerAttestationEnvelope { + v: 1, + s: status.code(), + t: token, + }) + .map_err(|err| warn!("failed to serialize app-server attestation envelope: {err}")) + .ok() +} + +#[cfg(test)] +mod tests { + use super::AppServerAttestationStatus; + use super::app_server_attestation_header_value; + use pretty_assertions::assert_eq; + + #[test] + fn app_server_attestation_header_value_wraps_opaque_client_payloads() { + assert_eq!( + app_server_attestation_header_value( + AppServerAttestationStatus::Ok, + Some("v1.opaque-client-payload"), + ), + Some(r#"{"v":1,"s":0,"t":"v1.opaque-client-payload"}"#.to_string()) + ); + } + + #[test] + fn app_server_attestation_header_value_reports_app_server_failures() { + assert_eq!( + app_server_attestation_header_value( + AppServerAttestationStatus::Timeout, + /*token*/ None, + ), + Some(r#"{"v":1,"s":1}"#.to_string()) + ); + assert_eq!( + app_server_attestation_header_value( + AppServerAttestationStatus::RequestFailed, + /*token*/ None, + ), + Some(r#"{"v":1,"s":2}"#.to_string()) + ); + assert_eq!( + app_server_attestation_header_value( + AppServerAttestationStatus::RequestCanceled, + /*token*/ None, + ), + Some(r#"{"v":1,"s":3}"#.to_string()) + ); + assert_eq!( + app_server_attestation_header_value( + AppServerAttestationStatus::MalformedResponse, + /*token*/ None + ), + Some(r#"{"v":1,"s":4}"#.to_string()) + ); + } +} diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index e702152356..ddd7bf1a38 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -1,17 +1,15 @@ -use crate::codex_message_processor::read_rollout_items_from_rollout; -use crate::codex_message_processor::read_summary_from_rollout; -use crate::codex_message_processor::summary_to_thread; use crate::error_code::internal_error; use crate::error_code::invalid_request; use crate::outgoing_message::ClientRequestResult; use crate::outgoing_message::ThreadScopedOutgoingMessageSender; +use crate::request_processors::populate_thread_turns_from_history; +use crate::request_processors::thread_from_stored_thread; use crate::server_request_error::is_turn_transition_server_request_error; use crate::thread_state::ThreadState; use crate::thread_state::TurnSummary; use crate::thread_state::resolve_server_request_on_thread_listener; use crate::thread_status::ThreadWatchActiveGuard; use crate::thread_status::ThreadWatchManager; -use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; use codex_app_server_protocol::AdditionalPermissionProfile as V2AdditionalPermissionProfile; use codex_app_server_protocol::CodexErrorInfo as V2CodexErrorInfo; @@ -51,10 +49,8 @@ use codex_app_server_protocol::RawResponseItemCompletedNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; -use codex_app_server_protocol::SkillsChangedNotification; use codex_app_server_protocol::ThreadGoalUpdatedNotification; use codex_app_server_protocol::ThreadItem; -use codex_app_server_protocol::ThreadNameUpdatedNotification; use codex_app_server_protocol::ThreadRealtimeClosedNotification; use codex_app_server_protocol::ThreadRealtimeErrorNotification; use codex_app_server_protocol::ThreadRealtimeItemAddedNotification; @@ -64,6 +60,7 @@ use codex_app_server_protocol::ThreadRealtimeStartedNotification; use codex_app_server_protocol::ThreadRealtimeTranscriptDeltaNotification; use codex_app_server_protocol::ThreadRealtimeTranscriptDoneNotification; use codex_app_server_protocol::ThreadRollbackResponse; +use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::ThreadTokenUsage; use codex_app_server_protocol::ThreadTokenUsageUpdatedNotification; use codex_app_server_protocol::ToolRequestUserInputOption; @@ -75,18 +72,17 @@ use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnDiffUpdatedNotification; use codex_app_server_protocol::TurnError; use codex_app_server_protocol::TurnInterruptResponse; +use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnPlanStep; use codex_app_server_protocol::TurnPlanUpdatedNotification; use codex_app_server_protocol::TurnStartedNotification; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::WarningNotification; use codex_app_server_protocol::build_item_from_guardian_event; -use codex_app_server_protocol::build_turns_from_rollout_items; use codex_app_server_protocol::guardian_auto_approval_review_notification; use codex_app_server_protocol::item_event_to_server_notification; use codex_core::CodexThread; use codex_core::ThreadManager; -use codex_core::find_thread_name_by_id; use codex_core::review_format::format_review_findings_block; use codex_core::review_prompts; use codex_protocol::ThreadId; @@ -114,12 +110,12 @@ use codex_sandboxing::policy_transforms::intersect_permission_profiles; use codex_shell_command::parse_command::shlex_join; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; -use std::path::Path; use std::sync::Arc; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use tokio::sync::Mutex; use tokio::sync::oneshot; use tracing::error; -use tracing::warn; enum CommandExecutionApprovalPresentation { Network(V2NetworkApprovalContext), @@ -139,13 +135,11 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id: ThreadId, conversation: Arc, thread_manager: Arc, - analytics_events_client: Option, outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, thread_list_state_permit: Arc, fallback_model_provider: String, - codex_home: &Path, ) { let Event { id: event_turn_id, @@ -160,24 +154,24 @@ pub(crate) async fn apply_bespoke_event_handling( .await; let turn = { let state = thread_state.lock().await; - state.active_turn_snapshot().unwrap_or_else(|| Turn { + let mut turn = state.active_turn_snapshot().unwrap_or_else(|| Turn { id: payload.turn_id.clone(), items: Vec::new(), + items_view: TurnItemsView::NotLoaded, error: None, status: TurnStatus::InProgress, started_at: payload.started_at, completed_at: None, duration_ms: None, - }) + }); + turn.items.clear(); + turn.items_view = TurnItemsView::NotLoaded; + turn }; let notification = TurnStartedNotification { thread_id: conversation_id.to_string(), turn, }; - if let Some(analytics_events_client) = analytics_events_client.as_ref() { - analytics_events_client - .track_notification(ServerNotification::TurnStarted(notification.clone())); - } outgoing .send_server_notification(ServerNotification::TurnStarted(notification)) .await; @@ -194,19 +188,11 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_complete_event, - analytics_events_client.as_ref(), &outgoing, &thread_state, ) .await; } - EventMsg::SkillsUpdateAvailable => { - outgoing - .send_server_notification(ServerNotification::SkillsChanged( - SkillsChangedNotification {}, - )) - .await; - } EventMsg::McpStartupUpdate(update) => { let (status, error) = match update.status { codex_protocol::protocol::McpStartupStatus::Starting => { @@ -236,10 +222,6 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: Some(conversation_id.to_string()), message: warning_event.message, }; - if let Some(analytics_events_client) = analytics_events_client.as_ref() { - analytics_events_client - .track_notification(ServerNotification::Warning(notification.clone())); - } outgoing .send_server_notification(ServerNotification::Warning(notification)) .await; @@ -249,10 +231,6 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), message: warning_event.message, }; - if let Some(analytics_events_client) = analytics_events_client.as_ref() { - analytics_events_client - .track_notification(ServerNotification::GuardianWarning(notification.clone())); - } outgoing .send_server_notification(ServerNotification::GuardianWarning(notification)) .await; @@ -525,6 +503,7 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn_id: event.turn_id.clone(), item_id: item_id.clone(), + started_at_ms: event.started_at_ms, reason: event.reason.clone(), grant_root: event.grant_root.clone(), }; @@ -556,6 +535,7 @@ pub(crate) async fn apply_bespoke_event_handling( call_id, approval_id, turn_id, + started_at_ms, command, cwd, reason, @@ -629,6 +609,7 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn_id: turn_id.clone(), item_id: call_id.clone(), + started_at_ms, approval_id: approval_id.clone(), reason, network_approval_context, @@ -778,6 +759,7 @@ pub(crate) async fn apply_bespoke_event_handling( thread_id: conversation_id.to_string(), turn_id: request.turn_id.clone(), item_id: request.call_id.clone(), + started_at_ms: request.started_at_ms, cwd: request_cwd.clone(), reason: request.reason, permissions: request.permissions.into(), @@ -790,6 +772,7 @@ pub(crate) async fn apply_bespoke_event_handling( requested_permissions, request_cwd, pending_request_id, + outgoing, receiver: rx, request_permissions_guard: permission_guard, }; @@ -816,6 +799,7 @@ pub(crate) async fn apply_bespoke_event_handling( let notification = ItemStartedNotification { thread_id: conversation_id.to_string(), turn_id: turn_id.clone(), + started_at_ms: request.started_at_ms, item, }; outgoing @@ -836,9 +820,11 @@ pub(crate) async fn apply_bespoke_event_handling( crate::dynamic_tools::on_call_response(call_id, rx, conversation).await; }); } + EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) => { + // Deprecated MCP tool-call events are still fanned out for legacy clients. + // App-server v2 receives the canonical TurnItem::McpToolCall lifecycle instead. + } msg @ (EventMsg::DynamicToolCallResponse(_) - | EventMsg::McpToolCallBegin(_) - | EventMsg::McpToolCallEnd(_) | EventMsg::CollabAgentSpawnBegin(_) | EventMsg::CollabAgentSpawnEnd(_) | EventMsg::CollabAgentInteractionBegin(_) @@ -966,6 +952,7 @@ pub(crate) async fn apply_bespoke_event_handling( let started = ItemStartedNotification { thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), + started_at_ms: now_unix_timestamp_ms(), item: item.clone(), }; outgoing @@ -974,6 +961,7 @@ pub(crate) async fn apply_bespoke_event_handling( let completed = ItemCompletedNotification { thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), + completed_at_ms: now_unix_timestamp_ms(), item, }; outgoing @@ -1023,6 +1011,7 @@ pub(crate) async fn apply_bespoke_event_handling( let started = ItemStartedNotification { thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), + started_at_ms: now_unix_timestamp_ms(), item: item.clone(), }; outgoing @@ -1031,6 +1020,7 @@ pub(crate) async fn apply_bespoke_event_handling( let completed = ItemCompletedNotification { thread_id: conversation_id.to_string(), turn_id: event_turn_id.clone(), + completed_at_ms: now_unix_timestamp_ms(), item, }; outgoing @@ -1130,7 +1120,6 @@ pub(crate) async fn apply_bespoke_event_handling( conversation_id, event_turn_id, turn_aborted_event, - analytics_events_client.as_ref(), &outgoing, &thread_state, ) @@ -1157,84 +1146,48 @@ pub(crate) async fn apply_bespoke_event_handling( return; } }; - let Some(rollout_path) = conversation.rollout_path() else { - outgoing - .send_error( - request_id, - invalid_request("thread has no persisted rollout"), - ) - .await; - return; - }; - let response = match read_summary_from_rollout( - rollout_path.as_path(), - fallback_model_provider.as_str(), - ) - .await + let fallback_cwd = conversation.config_snapshot().await.cwd; + let stored_thread = match conversation + .read_thread( + /*include_archived*/ true, /*include_history*/ true, + ) + .await { - Ok(summary) => { - let fallback_cwd = conversation.config_snapshot().await.cwd; - let mut thread = summary_to_thread(summary, &fallback_cwd); - match read_rollout_items_from_rollout(rollout_path.as_path()).await { - Ok(items) => { - thread.turns = build_turns_from_rollout_items(&items); - thread.status = thread_watch_manager - .loaded_status_for_thread(&thread.id) - .await; - match find_thread_name_by_id(codex_home, &conversation_id).await { - Ok(name) => { - thread.name = name; - } - Err(err) => { - warn!( - "Failed to read thread name for {conversation_id}: {err}" - ); - } - } - ThreadRollbackResponse { thread } - } - Err(err) => { - outgoing - .send_error( - request_id.clone(), - internal_error(format!( - "failed to load rollout `{}`: {err}", - rollout_path.display() - )), - ) - .await; - return; - } - } - } + Ok(stored_thread) => stored_thread, Err(err) => { outgoing .send_error( request_id.clone(), internal_error(format!( - "failed to load rollout `{}`: {err}", - rollout_path.display() + "failed to read thread {conversation_id} after rollback: {err}" )), ) .await; return; } }; + let loaded_status = thread_watch_manager + .loaded_status_for_thread(&conversation_id.to_string()) + .await; + let response = match thread_rollback_response_from_stored_thread( + stored_thread, + conversation.session_configured().session_id.to_string(), + fallback_model_provider.as_str(), + &fallback_cwd, + loaded_status, + ) { + Ok(response) => response, + Err(err) => { + outgoing + .send_error(request_id.clone(), internal_error(err)) + .await; + return; + } + }; outgoing.send_response(request_id, response).await; } } - EventMsg::ThreadNameUpdated(thread_name_event) => { - let notification = ThreadNameUpdatedNotification { - thread_id: thread_name_event.thread_id.to_string(), - thread_name: thread_name_event.thread_name, - }; - outgoing - .send_global_server_notification(ServerNotification::ThreadNameUpdated( - notification, - )) - .await; - } EventMsg::ThreadGoalUpdated(thread_goal_event) => { let notification = ThreadGoalUpdatedNotification { thread_id: thread_goal_event.thread_id.to_string(), @@ -1319,7 +1272,6 @@ async fn emit_turn_completed_with_status( conversation_id: ThreadId, event_turn_id: String, turn_completion_metadata: TurnCompletionMetadata, - analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, ) { let notification = TurnCompletedNotification { @@ -1327,6 +1279,7 @@ async fn emit_turn_completed_with_status( turn: Turn { id: event_turn_id, items: vec![], + items_view: TurnItemsView::NotLoaded, error: turn_completion_metadata.error, status: turn_completion_metadata.status, started_at: turn_completion_metadata.started_at, @@ -1334,10 +1287,6 @@ async fn emit_turn_completed_with_status( duration_ms: turn_completion_metadata.duration_ms, }, }; - if let Some(analytics_events_client) = analytics_events_client { - analytics_events_client - .track_notification(ServerNotification::TurnCompleted(notification.clone())); - } outgoing .send_server_notification(ServerNotification::TurnCompleted(notification)) .await; @@ -1366,6 +1315,7 @@ async fn start_command_execution_item( let notification = ItemStartedNotification { thread_id: conversation_id.to_string(), turn_id, + started_at_ms: now_unix_timestamp_ms(), item: ThreadItem::CommandExecution { id: item_id, command, @@ -1425,6 +1375,7 @@ async fn complete_command_execution_item( let notification = ItemCompletedNotification { thread_id: conversation_id.to_string(), turn_id, + completed_at_ms: now_unix_timestamp_ms(), item, }; outgoing @@ -1472,6 +1423,7 @@ pub(crate) async fn maybe_emit_hook_prompt_item_completed( let notification = ItemCompletedNotification { thread_id: conversation_id.to_string(), turn_id: turn_id.to_string(), + completed_at_ms: now_unix_timestamp_ms(), item: ThreadItem::HookPrompt { id: hook_prompt.id, fragments: hook_prompt @@ -1498,7 +1450,6 @@ async fn handle_turn_complete( conversation_id: ThreadId, event_turn_id: String, turn_complete_event: TurnCompleteEvent, - analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -1519,7 +1470,6 @@ async fn handle_turn_complete( completed_at: turn_complete_event.completed_at, duration_ms: turn_complete_event.duration_ms, }, - analytics_events_client, outgoing, ) .await; @@ -1529,7 +1479,6 @@ async fn handle_turn_interrupted( conversation_id: ThreadId, event_turn_id: String, turn_aborted_event: TurnAbortedEvent, - analytics_events_client: Option<&AnalyticsEventsClient>, outgoing: &ThreadScopedOutgoingMessageSender, thread_state: &Arc>, ) { @@ -1545,7 +1494,6 @@ async fn handle_turn_interrupted( completed_at: turn_aborted_event.completed_at, duration_ms: turn_aborted_event.duration_ms, }, - analytics_events_client, outgoing, ) .await; @@ -1566,6 +1514,27 @@ async fn handle_thread_rollback_failed( } } +fn thread_rollback_response_from_stored_thread( + stored_thread: codex_thread_store::StoredThread, + session_id: String, + fallback_model_provider: &str, + fallback_cwd: &AbsolutePathBuf, + loaded_status: ThreadStatus, +) -> std::result::Result { + let thread_id = stored_thread.thread_id; + let (mut thread, history) = + thread_from_stored_thread(stored_thread, fallback_model_provider, fallback_cwd); + thread.session_id = session_id; + let Some(history) = history else { + return Err(format!( + "thread {thread_id} did not include persisted history after rollback" + )); + }; + populate_thread_turns_from_history(&mut thread, &history.items, /*active_turn*/ None); + thread.status = loaded_status; + Ok(ThreadRollbackResponse { thread }) +} + async fn respond_to_pending_interrupts( thread_state: &Arc>, outgoing: &ThreadScopedOutgoingMessageSender, @@ -1777,11 +1746,12 @@ async fn on_request_permissions_response( requested_permissions, request_cwd, pending_request_id, + outgoing, receiver, request_permissions_guard, } = pending_response; let response = receiver.await; - resolve_server_request_on_thread_listener(&thread_state, pending_request_id).await; + resolve_server_request_on_thread_listener(&thread_state, pending_request_id.clone()).await; drop(request_permissions_guard); let Some(response) = request_permissions_response_from_client_result( requested_permissions, @@ -1790,6 +1760,7 @@ async fn on_request_permissions_response( ) else { return; }; + outgoing.track_effective_permissions_approval_response(pending_request_id, response.clone()); if let Err(err) = conversation .submit(Op::RequestPermissionsResponse { @@ -1807,6 +1778,7 @@ struct PendingRequestPermissionsResponse { requested_permissions: CoreRequestPermissionProfile, request_cwd: AbsolutePathBuf, pending_request_id: RequestId, + outgoing: ThreadScopedOutgoingMessageSender, receiver: oneshot::Receiver, request_permissions_guard: ThreadWatchActiveGuard, } @@ -2075,6 +2047,13 @@ async fn on_command_execution_request_approval_response( } } +fn now_unix_timestamp_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or_default() +} + #[cfg(test)] mod tests { use super::*; @@ -2086,11 +2065,11 @@ mod tests { use anyhow::Result; use anyhow::anyhow; use anyhow::bail; + use chrono::Utc; use codex_app_server_protocol::AutoReviewDecisionSource; use codex_app_server_protocol::GuardianApprovalReviewStatus; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::TurnPlanStepStatus; - use codex_login::AuthManager; use codex_login::CodexAuth; use codex_protocol::items::HookPromptFragment; use codex_protocol::items::build_hook_prompt_message; @@ -2102,20 +2081,28 @@ mod tests { use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::plan_tool::PlanItemArg; use codex_protocol::plan_tool::StepStatus; + use codex_protocol::protocol::AgentMessageEvent; + use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::CreditsSnapshot; + use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::SandboxPolicy; + use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::TokenUsageInfo; + use codex_protocol::protocol::UserMessageEvent; + use codex_thread_store::StoredThread; + use codex_thread_store::StoredThreadHistory; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use core_test_support::load_default_config_for_test; use pretty_assertions::assert_eq; use serde_json::json; - use std::path::PathBuf; use tempfile::TempDir; use tokio::sync::Mutex; use tokio::sync::mpsc; @@ -2140,6 +2127,73 @@ mod tests { } } + #[test] + fn rollback_response_rebuilds_pathless_thread_from_stored_history() -> Result<()> { + let thread_id = ThreadId::from_string("00000000-0000-0000-0000-000000000789")?; + let created_at = Utc::now(); + let history_items = vec![ + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "before rollback".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + })), + RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { + message: "after rollback".to_string(), + phase: None, + memory_citation: None, + })), + ]; + let stored_thread = StoredThread { + thread_id, + rollout_path: None, + forked_from_id: None, + preview: "fallback preview".to_string(), + name: Some("Rollback thread".to_string()), + model_provider: "openai".to_string(), + model: None, + reasoning_effort: None, + created_at, + updated_at: created_at, + archived_at: None, + cwd: test_path_buf("/tmp").abs().into(), + cli_version: "0.0.0".to_string(), + source: SessionSource::Cli, + thread_source: None, + agent_nickname: None, + agent_role: None, + agent_path: None, + git_info: None, + approval_mode: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + token_usage: None, + first_user_message: Some("before rollback".to_string()), + history: Some(StoredThreadHistory { + thread_id, + items: history_items, + }), + }; + let fallback_cwd = test_path_buf("/tmp").abs(); + + let response = thread_rollback_response_from_stored_thread( + stored_thread, + thread_id.to_string(), + "fallback-provider", + &fallback_cwd, + ThreadStatus::NotLoaded, + ) + .expect("rollback response should rebuild from stored history"); + + assert_eq!(response.thread.id, thread_id.to_string()); + assert_eq!(response.thread.path, None); + assert_eq!(response.thread.preview, "fallback preview"); + assert_eq!(response.thread.name.as_deref(), Some("Rollback thread")); + assert_eq!(response.thread.status, ThreadStatus::NotLoaded); + assert_eq!(response.thread.turns.len(), 1); + assert_eq!(response.thread.turns[0].items.len(), 2); + Ok(()) + } + fn turn_complete_event(turn_id: &str) -> TurnCompleteEvent { TurnCompleteEvent { turn_id: turn_id.to_string(), @@ -2195,6 +2249,9 @@ mod tests { id: format!("review-{id}"), target_item_id: Some(id.to_string()), turn_id: turn_id.to_string(), + started_at_ms: 1_000, + completed_at_ms: (!matches!(status, GuardianAssessmentStatus::InProgress)) + .then_some(1_042), status, risk_level, user_authorization, @@ -2221,8 +2278,6 @@ mod tests { outgoing: ThreadScopedOutgoingMessageSender, thread_state: Arc>, thread_watch_manager: ThreadWatchManager, - analytics_events_client: AnalyticsEventsClient, - codex_home: PathBuf, } impl GuardianAssessmentTestContext { @@ -2236,13 +2291,11 @@ mod tests { self.conversation_id, self.conversation.clone(), self.thread_manager.clone(), - Some(self.analytics_events_client.clone()), self.outgoing.clone(), self.thread_state.clone(), self.thread_watch_manager.clone(), Arc::new(tokio::sync::Semaphore::new(/*permits*/ 1)), "test-provider".to_string(), - &self.codex_home, ) .await; } @@ -2263,6 +2316,8 @@ mod tests { id: "review-1".to_string(), target_item_id: Some("item-1".to_string()), turn_id: String::new(), + started_at_ms: 1_000, + completed_at_ms: None, status: codex_protocol::protocol::GuardianAssessmentStatus::InProgress, risk_level: None, user_authorization: None, @@ -2276,6 +2331,7 @@ mod tests { ServerNotification::ItemGuardianApprovalReviewStarted(payload) => { assert_eq!(payload.thread_id, conversation_id.to_string()); assert_eq!(payload.turn_id, "turn-from-event"); + assert_eq!(payload.started_at_ms, 1_000); assert_eq!(payload.review_id, "review-1"); assert_eq!(payload.target_item_id.as_deref(), Some("item-1")); assert_eq!( @@ -2306,6 +2362,8 @@ mod tests { id: "review-2".to_string(), target_item_id: Some("item-2".to_string()), turn_id: "turn-from-assessment".to_string(), + started_at_ms: 1_000, + completed_at_ms: Some(1_042), status: codex_protocol::protocol::GuardianAssessmentStatus::Denied, risk_level: Some(codex_protocol::protocol::GuardianRiskLevel::High), user_authorization: Some(codex_protocol::protocol::GuardianUserAuthorization::Low), @@ -2321,6 +2379,8 @@ mod tests { ServerNotification::ItemGuardianApprovalReviewCompleted(payload) => { assert_eq!(payload.thread_id, conversation_id.to_string()); assert_eq!(payload.turn_id, "turn-from-assessment"); + assert_eq!(payload.started_at_ms, 1_000); + assert_eq!(payload.completed_at_ms, 1_042); assert_eq!(payload.review_id, "review-2"); assert_eq!(payload.target_item_id.as_deref(), Some("item-2")); assert_eq!(payload.decision_source, AutoReviewDecisionSource::Agent); @@ -2356,6 +2416,8 @@ mod tests { id: "review-3".to_string(), target_item_id: None, turn_id: "turn-from-assessment".to_string(), + started_at_ms: 1_000, + completed_at_ms: Some(1_042), status: codex_protocol::protocol::GuardianAssessmentStatus::Aborted, risk_level: None, user_authorization: None, @@ -2570,14 +2632,6 @@ mod tests { outgoing: outgoing.clone(), thread_state: thread_state.clone(), thread_watch_manager: thread_watch_manager.clone(), - analytics_events_client: AnalyticsEventsClient::new( - AuthManager::from_auth_for_testing( - CodexAuth::create_dummy_chatgpt_auth_for_testing(), - ), - "http://localhost".to_string(), - Some(false), - ), - codex_home: codex_home.path().to_path_buf(), }; guardian_context @@ -3120,6 +3174,90 @@ mod tests { Ok(()) } + #[tokio::test] + async fn turn_started_omits_active_snapshot_items() -> Result<()> { + let codex_home = TempDir::new()?; + let config = load_default_config_for_test(&codex_home).await; + let thread_manager = Arc::new( + codex_core::test_support::thread_manager_with_models_provider_and_home( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ), + ); + let codex_core::NewThread { + thread_id: conversation_id, + thread: conversation, + .. + } = thread_manager.start_thread(config.clone()).await?; + let thread_state = new_thread_state(); + { + let mut state = thread_state.lock().await; + state.track_current_turn_event( + "turn-1", + &EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { + turn_id: "turn-1".to_string(), + started_at: Some(42), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + ); + state.track_current_turn_event( + "turn-1", + &EventMsg::UserMessage(codex_protocol::protocol::UserMessageEvent { + message: "already tracked".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }), + ); + } + let thread_watch_manager = ThreadWatchManager::new(); + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + conversation_id, + ); + + apply_bespoke_event_handling( + Event { + id: "turn-1".to_string(), + msg: EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { + turn_id: "turn-1".to_string(), + started_at: Some(42), + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + }, + conversation_id, + conversation, + thread_manager, + outgoing, + thread_state, + thread_watch_manager, + Arc::new(tokio::sync::Semaphore::new(/*permits*/ 1)), + "test-provider".to_string(), + ) + .await; + + let msg = recv_broadcast_message(&mut rx).await?; + match msg { + OutgoingMessage::AppServerNotification(ServerNotification::TurnStarted(n)) => { + assert_eq!(n.turn.id, "turn-1"); + assert_eq!(n.turn.items_view, TurnItemsView::NotLoaded); + assert!(n.turn.items.is_empty()); + } + other => bail!("unexpected message: {other:?}"), + } + Ok(()) + } + #[tokio::test] async fn test_handle_turn_complete_emits_completed_without_error() -> Result<()> { let conversation_id = ThreadId::new(); @@ -3156,7 +3294,6 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), - /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3167,6 +3304,8 @@ mod tests { OutgoingMessage::AppServerNotification(ServerNotification::TurnCompleted(n)) => { assert_eq!(n.turn.id, event_turn_id); assert_eq!(n.turn.status, TurnStatus::Completed); + assert_eq!(n.turn.items_view, TurnItemsView::NotLoaded); + assert!(n.turn.items.is_empty()); assert_eq!(n.turn.error, None); assert_eq!(n.turn.started_at, Some(42)); assert_eq!(n.turn.completed_at, Some(TEST_TURN_COMPLETED_AT)); @@ -3208,7 +3347,6 @@ mod tests { conversation_id, event_turn_id.clone(), turn_aborted_event(&event_turn_id), - /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3259,7 +3397,6 @@ mod tests { conversation_id, event_turn_id.clone(), turn_complete_event(&event_turn_id), - /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3494,7 +3631,6 @@ mod tests { conversation_a, a_turn1.clone(), turn_complete_event(&a_turn1), - /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3516,7 +3652,6 @@ mod tests { conversation_b, b_turn1.clone(), turn_complete_event(&b_turn1), - /*analytics_events_client*/ None, &outgoing, &thread_state, ) @@ -3528,7 +3663,6 @@ mod tests { conversation_a, a_turn2.clone(), turn_complete_event(&a_turn2), - /*analytics_events_client*/ None, &outgoing, &thread_state, ) diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs deleted file mode 100644 index edc142c840..0000000000 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ /dev/null @@ -1,11225 +0,0 @@ -use crate::bespoke_event_handling::apply_bespoke_event_handling; -use crate::bespoke_event_handling::maybe_emit_hook_prompt_item_completed; -use crate::command_exec::CommandExecManager; -use crate::command_exec::StartCommandExecParams; -use crate::config_manager::ConfigManager; -use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE; -use crate::error_code::INTERNAL_ERROR_CODE; -use crate::error_code::INVALID_PARAMS_ERROR_CODE; -use crate::error_code::INVALID_REQUEST_ERROR_CODE; -use crate::error_code::invalid_params; -use crate::fuzzy_file_search::FuzzyFileSearchSession; -use crate::fuzzy_file_search::run_fuzzy_file_search; -use crate::fuzzy_file_search::start_fuzzy_file_search_session; -use crate::models::supported_models; -use crate::outgoing_message::ConnectionId; -use crate::outgoing_message::ConnectionRequestId; -use crate::outgoing_message::OutgoingMessageSender; -use crate::outgoing_message::RequestContext; -use crate::outgoing_message::ThreadScopedOutgoingMessageSender; -use crate::thread_status::ThreadWatchManager; -use crate::thread_status::resolve_thread_status; -use chrono::DateTime; -use chrono::Duration as ChronoDuration; -use chrono::SecondsFormat; -use chrono::Utc; -use codex_analytics::AnalyticsEventsClient; -use codex_analytics::AnalyticsJsonRpcError; -use codex_analytics::InputError; -use codex_analytics::TurnSteerRequestError; -use codex_app_server_protocol::Account; -use codex_app_server_protocol::AccountLoginCompletedNotification; -use codex_app_server_protocol::AccountUpdatedNotification; -use codex_app_server_protocol::AddCreditsNudgeCreditType; -use codex_app_server_protocol::AddCreditsNudgeEmailStatus; -use codex_app_server_protocol::AppInfo; -use codex_app_server_protocol::AppSummary; -use codex_app_server_protocol::AppsListParams; -use codex_app_server_protocol::AppsListResponse; -use codex_app_server_protocol::AskForApproval; -use codex_app_server_protocol::AuthMode; -use codex_app_server_protocol::CancelLoginAccountParams; -use codex_app_server_protocol::CancelLoginAccountResponse; -use codex_app_server_protocol::CancelLoginAccountStatus; -use codex_app_server_protocol::ClientRequest; -use codex_app_server_protocol::ClientResponsePayload; -use codex_app_server_protocol::CodexErrorInfo; -use codex_app_server_protocol::CollaborationModeListParams; -use codex_app_server_protocol::CollaborationModeListResponse; -use codex_app_server_protocol::CommandExecParams; -use codex_app_server_protocol::CommandExecResizeParams; -use codex_app_server_protocol::CommandExecTerminateParams; -use codex_app_server_protocol::CommandExecWriteParams; -use codex_app_server_protocol::ConversationGitInfo; -use codex_app_server_protocol::ConversationSummary; -use codex_app_server_protocol::DynamicToolSpec as ApiDynamicToolSpec; -use codex_app_server_protocol::ExperimentalFeature as ApiExperimentalFeature; -use codex_app_server_protocol::ExperimentalFeatureListParams; -use codex_app_server_protocol::ExperimentalFeatureListResponse; -use codex_app_server_protocol::ExperimentalFeatureStage as ApiExperimentalFeatureStage; -use codex_app_server_protocol::FeedbackUploadParams; -use codex_app_server_protocol::FeedbackUploadResponse; -use codex_app_server_protocol::FuzzyFileSearchParams; -use codex_app_server_protocol::FuzzyFileSearchResponse; -use codex_app_server_protocol::FuzzyFileSearchSessionStartParams; -use codex_app_server_protocol::FuzzyFileSearchSessionStartResponse; -use codex_app_server_protocol::FuzzyFileSearchSessionStopParams; -use codex_app_server_protocol::FuzzyFileSearchSessionStopResponse; -use codex_app_server_protocol::FuzzyFileSearchSessionUpdateParams; -use codex_app_server_protocol::FuzzyFileSearchSessionUpdateResponse; -use codex_app_server_protocol::GetAccountParams; -use codex_app_server_protocol::GetAccountRateLimitsResponse; -use codex_app_server_protocol::GetAccountResponse; -use codex_app_server_protocol::GetAuthStatusParams; -use codex_app_server_protocol::GetAuthStatusResponse; -use codex_app_server_protocol::GetConversationSummaryParams; -use codex_app_server_protocol::GetConversationSummaryResponse; -use codex_app_server_protocol::GitDiffToRemoteResponse; -use codex_app_server_protocol::GitInfo as ApiGitInfo; -use codex_app_server_protocol::HookMetadata; -use codex_app_server_protocol::HooksListParams; -use codex_app_server_protocol::HooksListResponse; -use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::ListMcpServerStatusParams; -use codex_app_server_protocol::ListMcpServerStatusResponse; -use codex_app_server_protocol::LoginAccountParams; -use codex_app_server_protocol::LoginAccountResponse; -use codex_app_server_protocol::LoginApiKeyParams; -use codex_app_server_protocol::LogoutAccountResponse; -use codex_app_server_protocol::MarketplaceAddParams; -use codex_app_server_protocol::MarketplaceAddResponse; -use codex_app_server_protocol::MarketplaceInterface; -use codex_app_server_protocol::MarketplaceRemoveParams; -use codex_app_server_protocol::MarketplaceRemoveResponse; -use codex_app_server_protocol::MarketplaceUpgradeErrorInfo; -use codex_app_server_protocol::MarketplaceUpgradeParams; -use codex_app_server_protocol::MarketplaceUpgradeResponse; -use codex_app_server_protocol::McpResourceReadParams; -use codex_app_server_protocol::McpResourceReadResponse; -use codex_app_server_protocol::McpServerOauthLoginCompletedNotification; -use codex_app_server_protocol::McpServerOauthLoginParams; -use codex_app_server_protocol::McpServerOauthLoginResponse; -use codex_app_server_protocol::McpServerRefreshResponse; -use codex_app_server_protocol::McpServerStatus; -use codex_app_server_protocol::McpServerStatusDetail; -use codex_app_server_protocol::McpServerToolCallParams; -use codex_app_server_protocol::McpServerToolCallResponse; -use codex_app_server_protocol::MemoryResetResponse; -use codex_app_server_protocol::MockExperimentalMethodParams; -use codex_app_server_protocol::MockExperimentalMethodResponse; -use codex_app_server_protocol::ModelListParams; -use codex_app_server_protocol::ModelListResponse; -use codex_app_server_protocol::PermissionProfileModificationParams; -use codex_app_server_protocol::PermissionProfileSelectionParams; -use codex_app_server_protocol::PluginDetail; -use codex_app_server_protocol::PluginInstallParams; -use codex_app_server_protocol::PluginInstallResponse; -use codex_app_server_protocol::PluginInterface; -use codex_app_server_protocol::PluginListParams; -use codex_app_server_protocol::PluginListResponse; -use codex_app_server_protocol::PluginMarketplaceEntry; -use codex_app_server_protocol::PluginReadParams; -use codex_app_server_protocol::PluginReadResponse; -use codex_app_server_protocol::PluginShareDeleteParams; -use codex_app_server_protocol::PluginShareDeleteResponse; -use codex_app_server_protocol::PluginShareListItem; -use codex_app_server_protocol::PluginShareListParams; -use codex_app_server_protocol::PluginShareListResponse; -use codex_app_server_protocol::PluginShareSaveParams; -use codex_app_server_protocol::PluginShareSaveResponse; -use codex_app_server_protocol::PluginSkillReadParams; -use codex_app_server_protocol::PluginSkillReadResponse; -use codex_app_server_protocol::PluginSource; -use codex_app_server_protocol::PluginSummary; -use codex_app_server_protocol::PluginUninstallParams; -use codex_app_server_protocol::PluginUninstallResponse; -use codex_app_server_protocol::RequestId; -use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery; -use codex_app_server_protocol::ReviewStartParams; -use codex_app_server_protocol::ReviewStartResponse; -use codex_app_server_protocol::ReviewTarget as ApiReviewTarget; -use codex_app_server_protocol::SandboxMode; -use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; -use codex_app_server_protocol::SendAddCreditsNudgeEmailResponse; -use codex_app_server_protocol::ServerNotification; -use codex_app_server_protocol::ServerRequestResolvedNotification; -use codex_app_server_protocol::SkillSummary; -use codex_app_server_protocol::SkillsConfigWriteParams; -use codex_app_server_protocol::SkillsConfigWriteResponse; -use codex_app_server_protocol::SkillsListParams; -use codex_app_server_protocol::SkillsListResponse; -use codex_app_server_protocol::SortDirection; -use codex_app_server_protocol::Thread; -use codex_app_server_protocol::ThreadApproveGuardianDeniedActionParams; -use codex_app_server_protocol::ThreadApproveGuardianDeniedActionResponse; -use codex_app_server_protocol::ThreadArchiveParams; -use codex_app_server_protocol::ThreadArchiveResponse; -use codex_app_server_protocol::ThreadArchivedNotification; -use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams; -use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse; -use codex_app_server_protocol::ThreadClosedNotification; -use codex_app_server_protocol::ThreadCompactStartParams; -use codex_app_server_protocol::ThreadCompactStartResponse; -use codex_app_server_protocol::ThreadDecrementElicitationParams; -use codex_app_server_protocol::ThreadDecrementElicitationResponse; -use codex_app_server_protocol::ThreadForkParams; -use codex_app_server_protocol::ThreadForkResponse; -use codex_app_server_protocol::ThreadGoal; -use codex_app_server_protocol::ThreadGoalClearParams; -use codex_app_server_protocol::ThreadGoalClearResponse; -use codex_app_server_protocol::ThreadGoalClearedNotification; -use codex_app_server_protocol::ThreadGoalGetParams; -use codex_app_server_protocol::ThreadGoalGetResponse; -use codex_app_server_protocol::ThreadGoalSetParams; -use codex_app_server_protocol::ThreadGoalSetResponse; -use codex_app_server_protocol::ThreadGoalStatus; -use codex_app_server_protocol::ThreadGoalUpdatedNotification; -use codex_app_server_protocol::ThreadIncrementElicitationParams; -use codex_app_server_protocol::ThreadIncrementElicitationResponse; -use codex_app_server_protocol::ThreadInjectItemsParams; -use codex_app_server_protocol::ThreadInjectItemsResponse; -use codex_app_server_protocol::ThreadItem; -use codex_app_server_protocol::ThreadListCwdFilter; -use codex_app_server_protocol::ThreadListParams; -use codex_app_server_protocol::ThreadListResponse; -use codex_app_server_protocol::ThreadLoadedListParams; -use codex_app_server_protocol::ThreadLoadedListResponse; -use codex_app_server_protocol::ThreadMemoryModeSetParams; -use codex_app_server_protocol::ThreadMemoryModeSetResponse; -use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams; -use codex_app_server_protocol::ThreadMetadataUpdateParams; -use codex_app_server_protocol::ThreadMetadataUpdateResponse; -use codex_app_server_protocol::ThreadNameUpdatedNotification; -use codex_app_server_protocol::ThreadReadParams; -use codex_app_server_protocol::ThreadReadResponse; -use codex_app_server_protocol::ThreadRealtimeAppendAudioParams; -use codex_app_server_protocol::ThreadRealtimeAppendAudioResponse; -use codex_app_server_protocol::ThreadRealtimeAppendTextParams; -use codex_app_server_protocol::ThreadRealtimeAppendTextResponse; -use codex_app_server_protocol::ThreadRealtimeListVoicesParams; -use codex_app_server_protocol::ThreadRealtimeListVoicesResponse; -use codex_app_server_protocol::ThreadRealtimeStartParams; -use codex_app_server_protocol::ThreadRealtimeStartResponse; -use codex_app_server_protocol::ThreadRealtimeStartTransport; -use codex_app_server_protocol::ThreadRealtimeStopParams; -use codex_app_server_protocol::ThreadRealtimeStopResponse; -use codex_app_server_protocol::ThreadResumeParams; -use codex_app_server_protocol::ThreadResumeResponse; -use codex_app_server_protocol::ThreadRollbackParams; -use codex_app_server_protocol::ThreadSetNameParams; -use codex_app_server_protocol::ThreadSetNameResponse; -use codex_app_server_protocol::ThreadShellCommandParams; -use codex_app_server_protocol::ThreadShellCommandResponse; -use codex_app_server_protocol::ThreadSortKey; -use codex_app_server_protocol::ThreadSourceKind; -use codex_app_server_protocol::ThreadStartParams; -use codex_app_server_protocol::ThreadStartResponse; -use codex_app_server_protocol::ThreadStartedNotification; -use codex_app_server_protocol::ThreadStatus; -use codex_app_server_protocol::ThreadTurnsListParams; -use codex_app_server_protocol::ThreadTurnsListResponse; -use codex_app_server_protocol::ThreadUnarchiveParams; -use codex_app_server_protocol::ThreadUnarchiveResponse; -use codex_app_server_protocol::ThreadUnarchivedNotification; -use codex_app_server_protocol::ThreadUnsubscribeParams; -use codex_app_server_protocol::ThreadUnsubscribeResponse; -use codex_app_server_protocol::ThreadUnsubscribeStatus; -use codex_app_server_protocol::Turn; -use codex_app_server_protocol::TurnEnvironmentParams; -use codex_app_server_protocol::TurnError; -use codex_app_server_protocol::TurnInterruptParams; -use codex_app_server_protocol::TurnInterruptResponse; -use codex_app_server_protocol::TurnStartParams; -use codex_app_server_protocol::TurnStartResponse; -use codex_app_server_protocol::TurnStatus; -use codex_app_server_protocol::TurnSteerParams; -use codex_app_server_protocol::TurnSteerResponse; -use codex_app_server_protocol::UserInput as V2UserInput; -use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification; -use codex_app_server_protocol::WindowsSandboxSetupMode; -use codex_app_server_protocol::WindowsSandboxSetupStartParams; -use codex_app_server_protocol::WindowsSandboxSetupStartResponse; -use codex_app_server_protocol::build_turns_from_rollout_items; -use codex_arg0::Arg0DispatchPaths; -use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType; -use codex_backend_client::Client as BackendClient; -use codex_chatgpt::connectors; -use codex_chatgpt::workspace_settings; -use codex_config::CloudRequirementsLoadError; -use codex_config::CloudRequirementsLoadErrorCode; -use codex_config::ConfigLayerStack; -use codex_config::loader::project_trust_key; -use codex_config::types::McpServerTransportConfig; -use codex_core::CodexThread; -use codex_core::CodexThreadTurnContextOverrides; -use codex_core::ForkSnapshot; -use codex_core::NewThread; -use codex_core::RolloutRecorder; -use codex_core::SessionMeta; -use codex_core::StartThreadOptions; -use codex_core::SteerInputError; -use codex_core::ThreadConfigSnapshot; -use codex_core::ThreadManager; -use codex_core::config::Config; -use codex_core::config::ConfigOverrides; -use codex_core::config::NetworkProxyAuditMetadata; -use codex_core::config::edit::ConfigEdit; -use codex_core::config::edit::ConfigEditsBuilder; -use codex_core::exec::ExecCapturePolicy; -use codex_core::exec::ExecExpiration; -use codex_core::exec::ExecParams; -use codex_core::exec_env::create_env; -use codex_core::find_archived_thread_path_by_id_str; -use codex_core::find_thread_name_by_id; -use codex_core::find_thread_path_by_id_str; -use codex_core::path_utils; -use codex_core::read_head_for_summary; -use codex_core::sandboxing::SandboxPermissions; -use codex_core::windows_sandbox::WindowsSandboxLevelExt; -use codex_core::windows_sandbox::WindowsSandboxSetupMode as CoreWindowsSandboxSetupMode; -use codex_core::windows_sandbox::WindowsSandboxSetupRequest; -use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; -use codex_core_plugins::PluginInstallError as CorePluginInstallError; -use codex_core_plugins::PluginInstallRequest; -use codex_core_plugins::PluginLoadOutcome; -use codex_core_plugins::PluginReadRequest; -use codex_core_plugins::PluginUninstallError as CorePluginUninstallError; -use codex_core_plugins::loader::load_plugin_apps; -use codex_core_plugins::loader::load_plugin_mcp_servers; -use codex_core_plugins::loader::plugin_telemetry_metadata_from_root; -use codex_core_plugins::manifest::PluginManifestInterface; -use codex_core_plugins::marketplace::MarketplaceError; -use codex_core_plugins::marketplace::MarketplacePluginSource; -use codex_core_plugins::marketplace_add::MarketplaceAddError; -use codex_core_plugins::marketplace_add::MarketplaceAddRequest; -use codex_core_plugins::marketplace_add::add_marketplace as add_marketplace_to_codex_home; -use codex_core_plugins::marketplace_remove::MarketplaceRemoveError; -use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest as CoreMarketplaceRemoveRequest; -use codex_core_plugins::marketplace_remove::remove_marketplace; -use codex_core_plugins::remote::RemoteMarketplace; -use codex_core_plugins::remote::RemotePluginCatalogError; -use codex_core_plugins::remote::RemotePluginDetail as RemoteCatalogPluginDetail; -use codex_core_plugins::remote::RemotePluginServiceConfig; -use codex_core_plugins::remote::RemotePluginShareSummary as RemoteCatalogPluginShareSummary; -use codex_core_plugins::remote::RemotePluginSummary as RemoteCatalogPluginSummary; -use codex_exec_server::EnvironmentManager; -use codex_exec_server::LOCAL_FS; -use codex_external_agent_sessions::ImportedExternalAgentSession; -use codex_features::FEATURES; -use codex_features::Feature; -use codex_features::Stage; -use codex_feedback::CodexFeedback; -use codex_feedback::FeedbackAttachmentPath; -use codex_feedback::FeedbackUploadOptions; -use codex_git_utils::git_diff_to_remote; -use codex_git_utils::resolve_root_git_project_for_trust; -use codex_login::AuthManager; -use codex_login::CLIENT_ID; -use codex_login::CodexAuth; -use codex_login::ServerOptions as LoginServerOptions; -use codex_login::ShutdownHandle; -use codex_login::auth::login_with_chatgpt_auth_tokens; -use codex_login::complete_device_code_login; -use codex_login::login_with_api_key; -use codex_login::request_device_code; -use codex_login::run_login_server; -use codex_mcp::McpRuntimeEnvironment; -use codex_mcp::McpServerStatusSnapshot; -use codex_mcp::McpSnapshotDetail; -use codex_mcp::collect_mcp_server_status_snapshot_with_detail; -use codex_mcp::discover_supported_scopes; -use codex_mcp::effective_mcp_servers; -use codex_mcp::read_mcp_resource as read_mcp_resource_without_thread; -use codex_mcp::resolve_oauth_scopes; -use codex_memories_write::clear_memory_roots_contents; -use codex_model_provider::ProviderAccountError; -use codex_model_provider::create_model_provider; -use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; -use codex_protocol::ThreadId; -use codex_protocol::config_types::CollaborationMode; -use codex_protocol::config_types::ForcedLoginMethod; -use codex_protocol::config_types::Personality; -use codex_protocol::config_types::TrustLevel; -use codex_protocol::config_types::WindowsSandboxLevel; -use codex_protocol::dynamic_tools::DynamicToolSpec as CoreDynamicToolSpec; -use codex_protocol::error::CodexErr; -use codex_protocol::error::Result as CodexResult; -use codex_protocol::items::TurnItem; -use codex_protocol::models::ResponseItem; -use codex_protocol::permissions::FileSystemSandboxPolicy; -use codex_protocol::protocol::AgentStatus; -use codex_protocol::protocol::ConversationAudioParams; -use codex_protocol::protocol::ConversationStartParams; -use codex_protocol::protocol::ConversationStartTransport; -use codex_protocol::protocol::ConversationTextParams; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::GitInfo as CoreGitInfo; -use codex_protocol::protocol::InitialHistory; -use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus; -use codex_protocol::protocol::McpServerRefreshConfig; -use codex_protocol::protocol::Op; -use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; -use codex_protocol::protocol::RealtimeVoicesList; -use codex_protocol::protocol::ResumedHistory; -use codex_protocol::protocol::ReviewDelivery as CoreReviewDelivery; -use codex_protocol::protocol::ReviewRequest; -use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; -use codex_protocol::protocol::RolloutItem; -use codex_protocol::protocol::SessionConfiguredEvent; -use codex_protocol::protocol::SessionMetaLine; -use codex_protocol::protocol::TurnEnvironmentSelection; -use codex_protocol::protocol::USER_MESSAGE_BEGIN; -use codex_protocol::protocol::W3cTraceContext; -use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; -use codex_protocol::user_input::UserInput as CoreInputItem; -use codex_rmcp_client::perform_oauth_login_return_url; -use codex_rollout::state_db::StateDbHandle; -use codex_rollout::state_db::get_state_db; -use codex_rollout::state_db::reconcile_rollout; -use codex_state::StateRuntime; -use codex_state::ThreadMetadata; -use codex_state::ThreadMetadataBuilder; -use codex_state::log_db::LogDbLayer; -use codex_thread_store::ArchiveThreadParams as StoreArchiveThreadParams; -use codex_thread_store::ListThreadsParams as StoreListThreadsParams; -use codex_thread_store::LocalThreadStore; -use codex_thread_store::ReadThreadByRolloutPathParams as StoreReadThreadByRolloutPathParams; -use codex_thread_store::ReadThreadParams as StoreReadThreadParams; -use codex_thread_store::SortDirection as StoreSortDirection; -use codex_thread_store::StoredThread; -use codex_thread_store::ThreadMetadataPatch as StoreThreadMetadataPatch; -use codex_thread_store::ThreadSortKey as StoreThreadSortKey; -use codex_thread_store::ThreadStore; -use codex_thread_store::ThreadStoreError; -use codex_thread_store::UpdateThreadMetadataParams as StoreUpdateThreadMetadataParams; -use codex_utils_absolute_path::AbsolutePathBuf; -use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; -use std::collections::HashMap; -use std::collections::HashSet; -use std::io::Error as IoError; -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::Ordering; -use std::time::Duration; -use std::time::Instant; -use tokio::sync::Mutex; -use tokio::sync::Semaphore; -use tokio::sync::SemaphorePermit; -use tokio::sync::broadcast; -use tokio::sync::oneshot; -use tokio::sync::watch; -use tokio_util::sync::CancellationToken; -use tokio_util::task::TaskTracker; -use toml::Value as TomlValue; -use tracing::Instrument; -use tracing::error; -use tracing::info; -use tracing::warn; -use uuid::Uuid; - -#[cfg(test)] -use codex_app_server_protocol::ServerRequest; - -mod apps_list_helpers; -mod plugin_app_helpers; -mod plugin_mcp_oauth; -mod plugins; -mod token_usage_replay; - -use crate::filters::compute_source_filters; -use crate::filters::source_kind_matches; -use crate::thread_state::ThreadListenerCommand; -use crate::thread_state::ThreadState; -use crate::thread_state::ThreadStateManager; -use token_usage_replay::latest_token_usage_turn_id_for_thread_path; -use token_usage_replay::latest_token_usage_turn_id_from_rollout_items; -use token_usage_replay::send_thread_token_usage_update_to_connection; - -const THREAD_LIST_DEFAULT_LIMIT: usize = 25; -const THREAD_LIST_MAX_LIMIT: usize = 100; -const THREAD_TURNS_DEFAULT_LIMIT: usize = 25; -const THREAD_TURNS_MAX_LIMIT: usize = 100; - -struct ThreadListFilters { - model_providers: Option>, - source_kinds: Option>, - archived: bool, - cwd_filters: Option>, - search_term: Option, - use_state_db_only: bool, -} - -// Duration before a browser ChatGPT login attempt is abandoned. -const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); -const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; -const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90); -const THREAD_UNLOADING_DELAY: Duration = Duration::from_secs(30 * 60); - -enum ActiveLogin { - Browser { - shutdown_handle: ShutdownHandle, - login_id: Uuid, - }, - DeviceCode { - cancel: CancellationToken, - login_id: Uuid, - }, -} - -impl ActiveLogin { - fn login_id(&self) -> Uuid { - match self { - ActiveLogin::Browser { login_id, .. } | ActiveLogin::DeviceCode { login_id, .. } => { - *login_id - } - } - } - - fn cancel(&self) { - match self { - ActiveLogin::Browser { - shutdown_handle, .. - } => shutdown_handle.shutdown(), - ActiveLogin::DeviceCode { cancel, .. } => cancel.cancel(), - } - } -} - -#[derive(Clone, Copy, Debug)] -enum CancelLoginError { - NotFound, -} - -enum AppListLoadResult { - Accessible(Result, String>), - Directory(Result, String>), -} - -enum ThreadShutdownResult { - Complete, - SubmitFailed, - TimedOut, -} - -enum ThreadReadViewError { - InvalidRequest(String), - Internal(String), -} - -mod thread_goal_handlers; -use self::thread_goal_handlers::api_thread_goal_from_state; - -fn thread_read_view_error(err: ThreadReadViewError) -> JSONRPCErrorError { - match err { - ThreadReadViewError::InvalidRequest(message) => invalid_request(message), - ThreadReadViewError::Internal(message) => internal_error(message), - } -} - -impl Drop for ActiveLogin { - fn drop(&mut self) { - self.cancel(); - } -} - -/// Handles JSON-RPC messages for Codex threads (and legacy conversation APIs). -#[derive(Clone)] -pub(crate) struct CodexMessageProcessor { - auth_manager: Arc, - thread_manager: Arc, - outgoing: Arc, - analytics_events_client: AnalyticsEventsClient, - arg0_paths: Arg0DispatchPaths, - config: Arc, - thread_store: Arc, - config_manager: ConfigManager, - active_login: Arc>>, - pending_thread_unloads: Arc>>, - thread_state_manager: ThreadStateManager, - thread_watch_manager: ThreadWatchManager, - /// Serializes mutations of list membership or fields rendered from list - /// results. `thread/list` is intentionally not serialized so it can run - /// concurrently against mostly append-only storage. - thread_list_state_permit: Arc, - command_exec_manager: CommandExecManager, - workspace_settings_cache: Arc, - pending_fuzzy_searches: Arc>>>, - fuzzy_search_sessions: Arc>>, - background_tasks: TaskTracker, - feedback: CodexFeedback, - log_db: Option, -} - -#[derive(Clone)] -struct ListenerTaskContext { - thread_manager: Arc, - thread_state_manager: ThreadStateManager, - outgoing: Arc, - pending_thread_unloads: Arc>>, - analytics_events_client: AnalyticsEventsClient, - thread_watch_manager: ThreadWatchManager, - thread_list_state_permit: Arc, - fallback_model_provider: String, - codex_home: PathBuf, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum EnsureConversationListenerResult { - Attached, - ConnectionClosed, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum RefreshTokenRequestOutcome { - NotAttemptedOrSucceeded, - FailedTransiently, - FailedPermanently, -} - -struct UnloadingState { - delay: Duration, - has_subscribers_rx: watch::Receiver, - has_subscribers: (bool, Instant), - thread_status_rx: watch::Receiver, - is_active: (bool, Instant), -} - -impl UnloadingState { - async fn new( - listener_task_context: &ListenerTaskContext, - thread_id: ThreadId, - delay: Duration, - ) -> Option { - let has_subscribers_rx = listener_task_context - .thread_state_manager - .subscribe_to_has_connections(thread_id) - .await?; - let thread_status_rx = listener_task_context - .thread_watch_manager - .subscribe(thread_id) - .await?; - let has_subscribers = (*has_subscribers_rx.borrow(), Instant::now()); - let is_active = ( - matches!(*thread_status_rx.borrow(), ThreadStatus::Active { .. }), - Instant::now(), - ); - Some(Self { - delay, - has_subscribers_rx, - thread_status_rx, - has_subscribers, - is_active, - }) - } - - fn unloading_target(&self) -> Option { - match (self.has_subscribers, self.is_active) { - ((false, has_no_subscribers_since), (false, is_inactive_since)) => { - Some(std::cmp::max(has_no_subscribers_since, is_inactive_since) + self.delay) - } - _ => None, - } - } - - fn sync_receiver_values(&mut self) { - let has_subscribers = *self.has_subscribers_rx.borrow(); - if self.has_subscribers.0 != has_subscribers { - self.has_subscribers = (has_subscribers, Instant::now()); - } - - let is_active = matches!(*self.thread_status_rx.borrow(), ThreadStatus::Active { .. }); - if self.is_active.0 != is_active { - self.is_active = (is_active, Instant::now()); - } - } - - fn should_unload_now(&mut self) -> bool { - self.sync_receiver_values(); - self.unloading_target() - .is_some_and(|target| target <= Instant::now()) - } - - fn note_thread_activity_observed(&mut self) { - if !self.is_active.0 { - self.is_active = (false, Instant::now()); - } - } - - async fn wait_for_unloading_trigger(&mut self) -> bool { - loop { - self.sync_receiver_values(); - let unloading_target = self.unloading_target(); - if let Some(target) = unloading_target - && target <= Instant::now() - { - return true; - } - let unloading_sleep = async { - if let Some(target) = unloading_target { - tokio::time::sleep_until(target.into()).await; - } else { - futures::future::pending::<()>().await; - } - }; - tokio::select! { - _ = unloading_sleep => return true, - changed = self.has_subscribers_rx.changed() => { - if changed.is_err() { - return false; - } - self.sync_receiver_values(); - }, - changed = self.thread_status_rx.changed() => { - if changed.is_err() { - return false; - } - self.sync_receiver_values(); - }, - } - } - } -} - -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, - /// Startup config used as the process baseline. Fresh effective config loads - /// go through `config_manager`. - pub(crate) config: Arc, - pub(crate) config_manager: ConfigManager, - pub(crate) thread_store: Arc, - pub(crate) feedback: CodexFeedback, - pub(crate) log_db: Option, -} - -fn environment_selection_error_message(err: CodexErr) -> String { - match err { - CodexErr::InvalidRequest(message) => message, - err => err.to_string(), - } -} - -impl CodexMessageProcessor { - async fn instruction_sources_from_config(config: &Config) -> Vec { - codex_core::AgentsMdManager::new(config) - .instruction_sources(LOCAL_FS.as_ref()) - .await - } - - /// Resolve a caller-provided cwd into the absolute cwd and matching config layers - /// so list-style RPCs share the same per-cwd error handling. - async fn resolve_cwd_config( - &self, - cwd: &Path, - ) -> Result<(AbsolutePathBuf, ConfigLayerStack), String> { - let cwd_abs = - AbsolutePathBuf::relative_to_current_dir(cwd).map_err(|err| err.to_string())?; - let config_layer_stack = self - .config_manager - .load_config_layers_for_cwd(cwd_abs.clone()) - .await - .map_err(|err| err.to_string())?; - - Ok((cwd_abs, config_layer_stack)) - } - - pub(crate) fn handle_config_mutation(&self) { - self.clear_plugin_related_caches(); - } - - pub(crate) fn effective_plugins_changed_callback( - &self, - config: Config, - ) -> Arc { - let thread_manager = Arc::clone(&self.thread_manager); - Arc::new(move || { - Self::spawn_effective_plugins_changed_task(Arc::clone(&thread_manager), config.clone()); - }) - } - - fn on_effective_plugins_changed(&self, config: Config) { - Self::spawn_effective_plugins_changed_task(Arc::clone(&self.thread_manager), config); - } - - fn spawn_effective_plugins_changed_task(thread_manager: Arc, config: Config) { - tokio::spawn(async move { - thread_manager.plugins_manager().clear_cache(); - thread_manager.skills_manager().clear_cache(); - if thread_manager.list_thread_ids().await.is_empty() { - return; - } - if let Err(err) = - Self::queue_mcp_server_refresh_for_config(&thread_manager, &config).await - { - warn!("failed to queue MCP refresh after effective plugins changed: {err:?}"); - } - }); - } - - fn clear_plugin_related_caches(&self) { - self.thread_manager.plugins_manager().clear_cache(); - self.thread_manager.skills_manager().clear_cache(); - } - - async fn maybe_refresh_remote_installed_plugins_cache_for_current_config( - config_manager: &ConfigManager, - thread_manager: &Arc, - auth: Option, - ) { - match config_manager - .load_latest_config(/*fallback_cwd*/ None) - .await - { - Ok(config) => { - let refresh_thread_manager = Arc::clone(thread_manager); - let refresh_config = config.clone(); - thread_manager - .plugins_manager() - .maybe_start_remote_installed_plugins_cache_refresh( - &config.plugins_config_input(), - auth, - Some(Arc::new(move || { - Self::spawn_effective_plugins_changed_task( - Arc::clone(&refresh_thread_manager), - refresh_config.clone(), - ); - })), - ); - } - Err(err) => { - warn!( - "failed to reload config after account changed, skipping remote installed plugins cache refresh: {err}" - ); - } - } - } - - fn current_account_updated_notification(&self) -> AccountUpdatedNotification { - let auth = self.auth_manager.auth_cached(); - AccountUpdatedNotification { - auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode), - plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type), - } - } - - fn track_error_response( - &self, - request_id: &ConnectionRequestId, - error: &JSONRPCErrorError, - error_type: Option, - ) { - self.analytics_events_client.track_error_response( - request_id.connection_id.0, - request_id.request_id.clone(), - error.clone(), - error_type, - ); - } - - async fn load_thread( - &self, - thread_id: &str, - ) -> Result<(ThreadId, Arc), JSONRPCErrorError> { - // Resolve the core conversation handle from a v2 thread id string. - let thread_id = ThreadId::from_string(thread_id).map_err(|err| JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid thread id: {err}"), - data: None, - })?; - - let thread = self - .thread_manager - .get_thread(thread_id) - .await - .map_err(|_| JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("thread not found: {thread_id}"), - data: None, - })?; - - Ok((thread_id, thread)) - } - pub fn new(args: CodexMessageProcessorArgs) -> Self { - let CodexMessageProcessorArgs { - auth_manager, - thread_manager, - outgoing, - analytics_events_client, - arg0_paths, - config, - config_manager, - thread_store, - feedback, - log_db, - } = args; - Self { - auth_manager, - thread_manager, - outgoing: outgoing.clone(), - analytics_events_client, - arg0_paths, - thread_store, - config, - config_manager, - active_login: Arc::new(Mutex::new(None)), - pending_thread_unloads: Arc::new(Mutex::new(HashSet::new())), - thread_state_manager: ThreadStateManager::new(), - thread_watch_manager: ThreadWatchManager::new_with_outgoing(outgoing), - thread_list_state_permit: Arc::new(Semaphore::new(/*permits*/ 1)), - command_exec_manager: CommandExecManager::default(), - workspace_settings_cache: Arc::new( - workspace_settings::WorkspaceSettingsCache::default(), - ), - pending_fuzzy_searches: Arc::new(Mutex::new(HashMap::new())), - fuzzy_search_sessions: Arc::new(Mutex::new(HashMap::new())), - background_tasks: TaskTracker::new(), - feedback, - log_db, - } - } - - async fn load_latest_config( - &self, - fallback_cwd: Option, - ) -> Result { - self.config_manager - .load_latest_config(fallback_cwd) - .await - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to reload config: {err}"), - data: None, - }) - } - - async fn workspace_codex_plugins_enabled( - &self, - config: &Config, - auth: Option<&CodexAuth>, - ) -> bool { - match workspace_settings::codex_plugins_enabled_for_workspace( - config, - auth, - Some(&self.workspace_settings_cache), - ) - .await - { - Ok(enabled) => enabled, - Err(err) => { - warn!( - "failed to fetch workspace Codex plugins setting; allowing Codex plugins: {err:#}" - ); - true - } - } - } - - /// If a client sends `developer_instructions: null` during a mode switch, - /// use the built-in instructions for that mode. - fn normalize_turn_start_collaboration_mode( - &self, - mut collaboration_mode: CollaborationMode, - ) -> CollaborationMode { - if collaboration_mode.settings.developer_instructions.is_none() - && let Some(instructions) = builtin_collaboration_mode_presets() - .into_iter() - .find(|preset| preset.mode == Some(collaboration_mode.mode)) - .and_then(|preset| preset.developer_instructions.flatten()) - .filter(|instructions| !instructions.is_empty()) - { - collaboration_mode.settings.developer_instructions = Some(instructions); - } - - collaboration_mode - } - - fn review_request_from_target( - target: ApiReviewTarget, - ) -> Result<(ReviewRequest, String), JSONRPCErrorError> { - fn invalid_request(message: String) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message, - data: None, - } - } - - let cleaned_target = match target { - ApiReviewTarget::UncommittedChanges => ApiReviewTarget::UncommittedChanges, - ApiReviewTarget::BaseBranch { branch } => { - let branch = branch.trim().to_string(); - if branch.is_empty() { - return Err(invalid_request("branch must not be empty".to_string())); - } - ApiReviewTarget::BaseBranch { branch } - } - ApiReviewTarget::Commit { sha, title } => { - let sha = sha.trim().to_string(); - if sha.is_empty() { - return Err(invalid_request("sha must not be empty".to_string())); - } - let title = title - .map(|t| t.trim().to_string()) - .filter(|t| !t.is_empty()); - ApiReviewTarget::Commit { sha, title } - } - ApiReviewTarget::Custom { instructions } => { - let trimmed = instructions.trim().to_string(); - if trimmed.is_empty() { - return Err(invalid_request( - "instructions must not be empty".to_string(), - )); - } - ApiReviewTarget::Custom { - instructions: trimmed, - } - } - }; - - let core_target = match cleaned_target { - ApiReviewTarget::UncommittedChanges => CoreReviewTarget::UncommittedChanges, - ApiReviewTarget::BaseBranch { branch } => CoreReviewTarget::BaseBranch { branch }, - ApiReviewTarget::Commit { sha, title } => CoreReviewTarget::Commit { sha, title }, - ApiReviewTarget::Custom { instructions } => CoreReviewTarget::Custom { instructions }, - }; - - let hint = codex_core::review_prompts::user_facing_hint(&core_target); - let review_request = ReviewRequest { - target: core_target, - user_facing_hint: Some(hint.clone()), - }; - - Ok((review_request, hint)) - } - - pub async fn process_request( - &self, - connection_id: ConnectionId, - request: ClientRequest, - app_server_client_name: Option, - app_server_client_version: Option, - request_context: RequestContext, - ) { - let to_connection_request_id = |request_id| ConnectionRequestId { - connection_id, - request_id, - }; - - match request { - ClientRequest::Initialize { .. } => { - panic!("Initialize should be handled in MessageProcessor"); - } - // === v2 Thread/Turn APIs === - ClientRequest::ThreadStart { request_id, params } => { - self.thread_start( - to_connection_request_id(request_id), - params, - app_server_client_name.clone(), - app_server_client_version.clone(), - request_context, - ) - .await; - } - ClientRequest::ThreadUnsubscribe { request_id, params } => { - self.thread_unsubscribe(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadResume { request_id, params } => { - self.thread_resume(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadFork { request_id, params } => { - self.thread_fork(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadArchive { request_id, params } => { - self.thread_archive(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadIncrementElicitation { request_id, params } => { - self.thread_increment_elicitation(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadDecrementElicitation { request_id, params } => { - self.thread_decrement_elicitation(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadSetName { request_id, params } => { - self.thread_set_name(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadGoalSet { request_id, params } => { - self.thread_goal_set(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadGoalGet { request_id, params } => { - self.thread_goal_get(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadGoalClear { request_id, params } => { - self.thread_goal_clear(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadMetadataUpdate { request_id, params } => { - self.thread_metadata_update(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadMemoryModeSet { request_id, params } => { - self.thread_memory_mode_set(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::MemoryReset { request_id, params } => { - self.memory_reset(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadUnarchive { request_id, params } => { - self.thread_unarchive(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadCompactStart { request_id, params } => { - self.thread_compact_start(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadBackgroundTerminalsClean { request_id, params } => { - self.thread_background_terminals_clean( - to_connection_request_id(request_id), - params, - ) - .await; - } - ClientRequest::ThreadRollback { request_id, params } => { - self.thread_rollback(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadList { request_id, params } => { - self.thread_list(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadLoadedList { request_id, params } => { - self.thread_loaded_list(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadRead { request_id, params } => { - self.thread_read(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadTurnsList { request_id, params } => { - self.thread_turns_list(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadShellCommand { request_id, params } => { - self.thread_shell_command(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadApproveGuardianDeniedAction { request_id, params } => { - self.thread_approve_guardian_denied_action( - to_connection_request_id(request_id), - params, - ) - .await; - } - ClientRequest::SkillsList { request_id, params } => { - self.skills_list(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::HooksList { request_id, params } => { - self.hooks_list(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::MarketplaceAdd { request_id, params } => { - self.marketplace_add(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::MarketplaceRemove { request_id, params } => { - self.marketplace_remove(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::MarketplaceUpgrade { request_id, params } => { - self.marketplace_upgrade(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::PluginList { request_id, params } => { - self.plugin_list(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::PluginRead { request_id, params } => { - self.plugin_read(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::PluginSkillRead { request_id, params } => { - self.plugin_skill_read(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::PluginShareSave { request_id, params } => { - self.plugin_share_save(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::PluginShareList { request_id, params } => { - self.plugin_share_list(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::PluginShareDelete { request_id, params } => { - self.plugin_share_delete(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::AppsList { request_id, params } => { - self.apps_list(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::SkillsConfigWrite { request_id, params } => { - self.skills_config_write(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::PluginInstall { request_id, params } => { - self.plugin_install(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::PluginUninstall { request_id, params } => { - self.plugin_uninstall(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::TurnStart { request_id, params } => { - self.turn_start( - to_connection_request_id(request_id), - params, - app_server_client_name.clone(), - app_server_client_version.clone(), - ) - .await; - } - ClientRequest::ThreadInjectItems { request_id, params } => { - self.thread_inject_items(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::TurnSteer { request_id, params } => { - self.turn_steer(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::TurnInterrupt { request_id, params } => { - self.turn_interrupt(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadRealtimeStart { request_id, params } => { - self.thread_realtime_start(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadRealtimeAppendAudio { request_id, params } => { - self.thread_realtime_append_audio(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadRealtimeAppendText { request_id, params } => { - self.thread_realtime_append_text(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadRealtimeStop { request_id, params } => { - self.thread_realtime_stop(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ThreadRealtimeListVoices { request_id, params } => { - self.thread_realtime_list_voices(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ReviewStart { request_id, params } => { - self.review_start(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::GetConversationSummary { request_id, params } => { - self.get_thread_summary(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::ModelList { request_id, params } => { - let outgoing = self.outgoing.clone(); - let thread_manager = self.thread_manager.clone(); - let request_id = to_connection_request_id(request_id); - - tokio::spawn(async move { - Self::list_models(outgoing, thread_manager, request_id, params).await; - }); - } - ClientRequest::ExperimentalFeatureList { request_id, params } => { - self.experimental_feature_list(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::CollaborationModeList { request_id, params } => { - let outgoing = self.outgoing.clone(); - let thread_manager = self.thread_manager.clone(); - let request_id = to_connection_request_id(request_id); - - tokio::spawn(async move { - Self::list_collaboration_modes(outgoing, thread_manager, request_id, params) - .await; - }); - } - ClientRequest::MockExperimentalMethod { request_id, params } => { - self.mock_experimental_method(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::McpServerOauthLogin { request_id, params } => { - self.mcp_server_oauth_login(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::McpServerRefresh { request_id, params } => { - self.mcp_server_refresh(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::McpServerStatusList { request_id, params } => { - self.list_mcp_server_status(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::McpResourceRead { request_id, params } => { - self.read_mcp_resource(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::McpServerToolCall { request_id, params } => { - self.call_mcp_server_tool(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::WindowsSandboxSetupStart { request_id, params } => { - self.windows_sandbox_setup_start(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::LoginAccount { request_id, params } => { - self.login_v2(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::LogoutAccount { - request_id, - params: _, - } => { - self.logout_v2(to_connection_request_id(request_id)).await; - } - ClientRequest::CancelLoginAccount { request_id, params } => { - self.cancel_login_v2(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::GetAccount { request_id, params } => { - self.get_account(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::GitDiffToRemote { request_id, params } => { - self.git_diff_to_origin(to_connection_request_id(request_id), params.cwd) - .await; - } - ClientRequest::GetAuthStatus { request_id, params } => { - self.get_auth_status(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::FuzzyFileSearch { request_id, params } => { - self.fuzzy_file_search(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::FuzzyFileSearchSessionStart { request_id, params } => { - self.fuzzy_file_search_session_start(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::FuzzyFileSearchSessionUpdate { request_id, params } => { - self.fuzzy_file_search_session_update(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::FuzzyFileSearchSessionStop { request_id, params } => { - self.fuzzy_file_search_session_stop(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::OneOffCommandExec { request_id, params } => { - self.exec_one_off_command(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::CommandExecWrite { request_id, params } => { - self.command_exec_write(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::CommandExecResize { request_id, params } => { - self.command_exec_resize(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::CommandExecTerminate { request_id, params } => { - self.command_exec_terminate(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::DeviceKeyCreate { .. } - | ClientRequest::DeviceKeyPublic { .. } - | ClientRequest::DeviceKeySign { .. } => { - warn!("Device key request reached CodexMessageProcessor unexpectedly"); - } - ClientRequest::ConfigRead { .. } - | ClientRequest::ConfigValueWrite { .. } - | ClientRequest::ConfigBatchWrite { .. } - | ClientRequest::ExperimentalFeatureEnablementSet { .. } => { - warn!("Config request reached CodexMessageProcessor unexpectedly"); - } - ClientRequest::FsReadFile { .. } - | ClientRequest::FsWriteFile { .. } - | ClientRequest::FsCreateDirectory { .. } - | ClientRequest::FsGetMetadata { .. } - | ClientRequest::FsReadDirectory { .. } - | ClientRequest::FsRemove { .. } - | ClientRequest::FsCopy { .. } - | ClientRequest::FsWatch { .. } - | ClientRequest::FsUnwatch { .. } => { - warn!("Filesystem request reached CodexMessageProcessor unexpectedly"); - } - ClientRequest::ConfigRequirementsRead { .. } => { - warn!("ConfigRequirementsRead request reached CodexMessageProcessor unexpectedly"); - } - ClientRequest::ModelProviderCapabilitiesRead { .. } => { - warn!( - "ModelProviderCapabilitiesRead request reached CodexMessageProcessor unexpectedly" - ); - } - ClientRequest::ExternalAgentConfigDetect { .. } - | ClientRequest::ExternalAgentConfigImport { .. } => { - warn!("ExternalAgentConfig request reached CodexMessageProcessor unexpectedly"); - } - ClientRequest::GetAccountRateLimits { - request_id, - params: _, - } => { - self.get_account_rate_limits(to_connection_request_id(request_id)) - .await; - } - ClientRequest::SendAddCreditsNudgeEmail { request_id, params } => { - self.send_add_credits_nudge_email(to_connection_request_id(request_id), params) - .await; - } - ClientRequest::FeedbackUpload { request_id, params } => { - self.upload_feedback(to_connection_request_id(request_id), params) - .await; - } - } - } - - async fn login_v2(&self, request_id: ConnectionRequestId, params: LoginAccountParams) { - match params { - LoginAccountParams::ApiKey { api_key } => { - self.login_api_key_v2(request_id, LoginApiKeyParams { api_key }) - .await; - } - LoginAccountParams::Chatgpt { - codex_streamlined_login, - } => { - self.login_chatgpt_v2(request_id, codex_streamlined_login) - .await; - } - LoginAccountParams::ChatgptDeviceCode => { - self.login_chatgpt_device_code_v2(request_id).await; - } - LoginAccountParams::ChatgptAuthTokens { - access_token, - chatgpt_account_id, - chatgpt_plan_type, - } => { - self.login_chatgpt_auth_tokens( - request_id, - access_token, - chatgpt_account_id, - chatgpt_plan_type, - ) - .await; - } - } - } - - fn external_auth_active_error(&self) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "External auth is active. Use account/login/start (chatgptAuthTokens) to update it or account/logout to clear it." - .to_string(), - data: None, - } - } - - async fn acquire_thread_list_state_permit( - &self, - ) -> Result, JSONRPCErrorError> { - self.thread_list_state_permit - .acquire() - .await - .map_err(|err| { - internal_error(format!("failed to acquire thread list state permit: {err}")) - }) - } - - async fn login_api_key_common( - &self, - params: &LoginApiKeyParams, - ) -> std::result::Result<(), JSONRPCErrorError> { - if self.auth_manager.is_external_chatgpt_auth_active() { - return Err(self.external_auth_active_error()); - } - - if matches!( - self.config.forced_login_method, - Some(ForcedLoginMethod::Chatgpt) - ) { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "API key login is disabled. Use ChatGPT login instead.".to_string(), - data: None, - }); - } - - // Cancel any active login attempt. - { - let mut guard = self.active_login.lock().await; - if let Some(active) = guard.take() { - drop(active); - } - } - - match login_with_api_key( - &self.config.codex_home, - ¶ms.api_key, - self.config.cli_auth_credentials_store_mode, - ) { - Ok(()) => { - self.auth_manager.reload().await; - Ok(()) - } - Err(err) => Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to save api key: {err}"), - data: None, - }), - } - } - - async fn login_api_key_v2(&self, request_id: ConnectionRequestId, params: LoginApiKeyParams) { - let result = self - .login_api_key_common(¶ms) - .await - .map(|()| LoginAccountResponse::ApiKey {}); - let logged_in = result.is_ok(); - self.outgoing.send_result(request_id, result).await; - - if logged_in { - self.send_login_success_notifications(/*login_id*/ None) - .await; - } - } - - // Build options for a ChatGPT login attempt; performs validation. - async fn login_chatgpt_common( - &self, - codex_streamlined_login: bool, - ) -> std::result::Result { - let config = self.config.as_ref(); - - if self.auth_manager.is_external_chatgpt_auth_active() { - return Err(self.external_auth_active_error()); - } - - if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "ChatGPT login is disabled. Use API key login instead.".to_string(), - data: None, - }); - } - - let opts = LoginServerOptions { - open_browser: false, - codex_streamlined_login, - ..LoginServerOptions::new( - config.codex_home.to_path_buf(), - CLIENT_ID.to_string(), - config.forced_chatgpt_workspace_id.clone(), - config.cli_auth_credentials_store_mode, - ) - }; - #[cfg(debug_assertions)] - let opts = { - let mut opts = opts; - if let Ok(issuer) = std::env::var(LOGIN_ISSUER_OVERRIDE_ENV_VAR) - && !issuer.trim().is_empty() - { - opts.issuer = issuer; - } - opts - }; - - Ok(opts) - } - - fn login_chatgpt_device_code_start_error(err: IoError) -> JSONRPCErrorError { - let is_not_found = err.kind() == std::io::ErrorKind::NotFound; - JSONRPCErrorError { - code: if is_not_found { - INVALID_REQUEST_ERROR_CODE - } else { - INTERNAL_ERROR_CODE - }, - message: if is_not_found { - err.to_string() - } else { - format!("failed to request device code: {err}") - }, - data: None, - } - } - - async fn login_chatgpt_v2( - &self, - request_id: ConnectionRequestId, - codex_streamlined_login: bool, - ) { - let result = self.login_chatgpt_response(codex_streamlined_login).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn login_chatgpt_response( - &self, - codex_streamlined_login: bool, - ) -> Result { - let opts = self.login_chatgpt_common(codex_streamlined_login).await?; - let server = run_login_server(opts) - .map_err(|err| internal_error(format!("failed to start login server: {err}")))?; - let login_id = Uuid::new_v4(); - let shutdown_handle = server.cancel_handle(); - - // Replace active login if present. - { - let mut guard = self.active_login.lock().await; - if let Some(existing) = guard.take() { - drop(existing); - } - *guard = Some(ActiveLogin::Browser { - shutdown_handle: shutdown_handle.clone(), - login_id, - }); - } - - let outgoing_clone = self.outgoing.clone(); - let config_manager = self.config_manager.clone(); - let thread_manager = Arc::clone(&self.thread_manager); - let chatgpt_base_url = self.config.chatgpt_base_url.clone(); - let active_login = self.active_login.clone(); - let auth_url = server.auth_url.clone(); - tokio::spawn(async move { - let (success, error_msg) = match tokio::time::timeout( - LOGIN_CHATGPT_TIMEOUT, - server.block_until_done(), - ) - .await - { - Ok(Ok(())) => (true, None), - Ok(Err(err)) => (false, Some(format!("Login server error: {err}"))), - Err(_elapsed) => { - shutdown_handle.shutdown(); - (false, Some("Login timed out".to_string())) - } - }; - - Self::send_chatgpt_login_completion_notifications( - &outgoing_clone, - config_manager, - thread_manager, - chatgpt_base_url, - login_id, - success, - error_msg, - ) - .await; - - // Clear the active login if it matches this attempt. It may have been replaced or cancelled. - let mut guard = active_login.lock().await; - if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { - *guard = None; - } - }); - - Ok(LoginAccountResponse::Chatgpt { - login_id: login_id.to_string(), - auth_url, - }) - } - - async fn login_chatgpt_device_code_v2(&self, request_id: ConnectionRequestId) { - let result = self.login_chatgpt_device_code_response().await; - self.outgoing.send_result(request_id, result).await; - } - - async fn login_chatgpt_device_code_response( - &self, - ) -> Result { - let opts = self - .login_chatgpt_common(/*codex_streamlined_login*/ false) - .await?; - let device_code = request_device_code(&opts) - .await - .map_err(Self::login_chatgpt_device_code_start_error)?; - let login_id = Uuid::new_v4(); - let cancel = CancellationToken::new(); - - { - let mut guard = self.active_login.lock().await; - if let Some(existing) = guard.take() { - drop(existing); - } - *guard = Some(ActiveLogin::DeviceCode { - cancel: cancel.clone(), - login_id, - }); - } - - let verification_url = device_code.verification_url.clone(); - let user_code = device_code.user_code.clone(); - - let outgoing_clone = self.outgoing.clone(); - let config_manager = self.config_manager.clone(); - let thread_manager = Arc::clone(&self.thread_manager); - let chatgpt_base_url = self.config.chatgpt_base_url.clone(); - let active_login = self.active_login.clone(); - tokio::spawn(async move { - let (success, error_msg) = tokio::select! { - _ = cancel.cancelled() => { - (false, Some("Login was not completed".to_string())) - } - r = complete_device_code_login(opts, device_code) => { - match r { - Ok(()) => (true, None), - Err(err) => (false, Some(err.to_string())), - } - } - }; - - Self::send_chatgpt_login_completion_notifications( - &outgoing_clone, - config_manager, - thread_manager, - chatgpt_base_url, - login_id, - success, - error_msg, - ) - .await; - - let mut guard = active_login.lock().await; - if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { - *guard = None; - } - }); - - Ok(LoginAccountResponse::ChatgptDeviceCode { - login_id: login_id.to_string(), - verification_url, - user_code, - }) - } - - async fn cancel_login_chatgpt_common( - &self, - login_id: Uuid, - ) -> std::result::Result<(), CancelLoginError> { - let mut guard = self.active_login.lock().await; - if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { - if let Some(active) = guard.take() { - drop(active); - } - Ok(()) - } else { - Err(CancelLoginError::NotFound) - } - } - - async fn cancel_login_v2( - &self, - request_id: ConnectionRequestId, - params: CancelLoginAccountParams, - ) { - let result = self.cancel_login_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn cancel_login_response( - &self, - params: CancelLoginAccountParams, - ) -> Result { - let login_id = params.login_id; - let uuid = Uuid::parse_str(&login_id) - .map_err(|_| invalid_request(format!("invalid login id: {login_id}")))?; - let status = match self.cancel_login_chatgpt_common(uuid).await { - Ok(()) => CancelLoginAccountStatus::Canceled, - Err(CancelLoginError::NotFound) => CancelLoginAccountStatus::NotFound, - }; - Ok(CancelLoginAccountResponse { status }) - } - - async fn login_chatgpt_auth_tokens( - &self, - request_id: ConnectionRequestId, - access_token: String, - chatgpt_account_id: String, - chatgpt_plan_type: Option, - ) { - let result = self - .login_chatgpt_auth_tokens_response(access_token, chatgpt_account_id, chatgpt_plan_type) - .await; - let logged_in = result.is_ok(); - self.outgoing.send_result(request_id, result).await; - - if logged_in { - self.send_login_success_notifications(/*login_id*/ None) - .await; - } - } - - async fn login_chatgpt_auth_tokens_response( - &self, - access_token: String, - chatgpt_account_id: String, - chatgpt_plan_type: Option, - ) -> Result { - if matches!( - self.config.forced_login_method, - Some(ForcedLoginMethod::Api) - ) { - return Err(invalid_request( - "External ChatGPT auth is disabled. Use API key login instead.", - )); - } - - // Cancel any active login attempt to avoid persisting managed auth state. - { - let mut guard = self.active_login.lock().await; - if let Some(active) = guard.take() { - drop(active); - } - } - - if let Some(expected_workspace) = self.config.forced_chatgpt_workspace_id.as_deref() - && chatgpt_account_id != expected_workspace - { - return Err(invalid_request(format!( - "External auth must use workspace {expected_workspace}, but received {chatgpt_account_id:?}." - ))); - } - - login_with_chatgpt_auth_tokens( - &self.config.codex_home, - &access_token, - &chatgpt_account_id, - chatgpt_plan_type.as_deref(), - ) - .map_err(|err| internal_error(format!("failed to set external auth: {err}")))?; - self.auth_manager.reload().await; - self.config_manager.replace_cloud_requirements_loader( - self.auth_manager.clone(), - self.config.chatgpt_base_url.clone(), - ); - self.config_manager - .sync_default_client_residency_requirement() - .await; - - Ok(LoginAccountResponse::ChatgptAuthTokens {}) - } - - async fn send_login_success_notifications(&self, login_id: Option) { - Self::maybe_refresh_remote_installed_plugins_cache_for_current_config( - &self.config_manager, - &self.thread_manager, - self.auth_manager.auth_cached(), - ) - .await; - - let payload_login_completed = AccountLoginCompletedNotification { - login_id: login_id.map(|id| id.to_string()), - success: true, - error: None, - }; - self.outgoing - .send_server_notification(ServerNotification::AccountLoginCompleted( - payload_login_completed, - )) - .await; - - self.outgoing - .send_server_notification(ServerNotification::AccountUpdated( - self.current_account_updated_notification(), - )) - .await; - } - - async fn send_chatgpt_login_completion_notifications( - outgoing: &OutgoingMessageSender, - config_manager: ConfigManager, - thread_manager: Arc, - chatgpt_base_url: String, - login_id: Uuid, - success: bool, - error_msg: Option, - ) { - let payload_v2 = AccountLoginCompletedNotification { - login_id: Some(login_id.to_string()), - success, - error: error_msg, - }; - outgoing - .send_server_notification(ServerNotification::AccountLoginCompleted(payload_v2)) - .await; - - if success { - let auth_manager = thread_manager.auth_manager(); - auth_manager.reload().await; - config_manager - .replace_cloud_requirements_loader(auth_manager.clone(), chatgpt_base_url); - config_manager - .sync_default_client_residency_requirement() - .await; - - let auth = auth_manager.auth_cached(); - Self::maybe_refresh_remote_installed_plugins_cache_for_current_config( - &config_manager, - &thread_manager, - auth.clone(), - ) - .await; - let payload_v2 = AccountUpdatedNotification { - auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode), - plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type), - }; - outgoing - .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) - .await; - } - } - - async fn logout_common(&self) -> std::result::Result, JSONRPCErrorError> { - // Cancel any active login attempt. - { - let mut guard = self.active_login.lock().await; - if let Some(active) = guard.take() { - drop(active); - } - } - - match self.auth_manager.logout_with_revoke().await { - Ok(_) => {} - Err(err) => { - return Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("logout failed: {err}"), - data: None, - }); - } - } - - Self::maybe_refresh_remote_installed_plugins_cache_for_current_config( - &self.config_manager, - &self.thread_manager, - self.auth_manager.auth_cached(), - ) - .await; - - // Reflect the current auth method after logout (likely None). - Ok(self - .auth_manager - .auth_cached() - .as_ref() - .map(CodexAuth::api_auth_mode)) - } - - async fn logout_v2(&self, request_id: ConnectionRequestId) { - let result = self.logout_common().await; - let account_updated = - result - .as_ref() - .ok() - .cloned() - .map(|auth_mode| AccountUpdatedNotification { - auth_mode, - plan_type: None, - }); - self.outgoing - .send_result(request_id, result.map(|_| LogoutAccountResponse {})) - .await; - - if let Some(payload) = account_updated { - self.outgoing - .send_server_notification(ServerNotification::AccountUpdated(payload)) - .await; - } - } - - async fn refresh_token_if_requested(&self, do_refresh: bool) -> RefreshTokenRequestOutcome { - if self.auth_manager.is_external_chatgpt_auth_active() { - return RefreshTokenRequestOutcome::NotAttemptedOrSucceeded; - } - if do_refresh && let Err(err) = self.auth_manager.refresh_token().await { - let failed_reason = err.failed_reason(); - if failed_reason.is_none() { - tracing::warn!("failed to refresh token while getting account: {err}"); - return RefreshTokenRequestOutcome::FailedTransiently; - } - return RefreshTokenRequestOutcome::FailedPermanently; - } - RefreshTokenRequestOutcome::NotAttemptedOrSucceeded - } - - async fn get_auth_status(&self, request_id: ConnectionRequestId, params: GetAuthStatusParams) { - let include_token = params.include_token.unwrap_or(false); - let do_refresh = params.refresh_token.unwrap_or(false); - - self.refresh_token_if_requested(do_refresh).await; - - // Determine whether auth is required based on the active model provider. - // If a custom provider is configured with `requires_openai_auth == false`, - // then no auth step is required; otherwise, default to requiring auth. - let requires_openai_auth = self.config.model_provider.requires_openai_auth; - - let response = if !requires_openai_auth { - GetAuthStatusResponse { - auth_method: None, - auth_token: None, - requires_openai_auth: Some(false), - } - } else { - let auth = if do_refresh { - self.auth_manager.auth_cached() - } else { - self.auth_manager.auth().await - }; - match auth { - Some(auth) => { - let permanent_refresh_failure = - self.auth_manager.refresh_failure_for_auth(&auth).is_some(); - let auth_mode = auth.api_auth_mode(); - let (reported_auth_method, token_opt) = - if matches!(auth, CodexAuth::AgentIdentity(_)) - || include_token && permanent_refresh_failure - { - (Some(auth_mode), None) - } else { - match auth.get_token() { - Ok(token) if !token.is_empty() => { - let tok = if include_token { Some(token) } else { None }; - (Some(auth_mode), tok) - } - Ok(_) => (None, None), - Err(err) => { - tracing::warn!("failed to get token for auth status: {err}"); - (None, None) - } - } - }; - GetAuthStatusResponse { - auth_method: reported_auth_method, - auth_token: token_opt, - requires_openai_auth: Some(true), - } - } - None => GetAuthStatusResponse { - auth_method: None, - auth_token: None, - requires_openai_auth: Some(true), - }, - } - }; - - self.outgoing.send_response(request_id, response).await; - } - - async fn get_account(&self, request_id: ConnectionRequestId, params: GetAccountParams) { - let result = self.get_account_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn get_account_response( - &self, - params: GetAccountParams, - ) -> Result { - let do_refresh = params.refresh_token; - - self.refresh_token_if_requested(do_refresh).await; - - let provider = create_model_provider( - self.config.model_provider.clone(), - Some(self.auth_manager.clone()), - ); - let account_state = match provider.account_state() { - Ok(account_state) => account_state, - Err(ProviderAccountError::MissingChatgptAccountDetails) => { - return Err(invalid_request( - "email and plan type are required for chatgpt authentication", - )); - } - }; - let account = account_state.account.map(Account::from); - - Ok(GetAccountResponse { - account, - requires_openai_auth: account_state.requires_openai_auth, - }) - } - - async fn get_account_rate_limits(&self, request_id: ConnectionRequestId) { - let result = - self.fetch_account_rate_limits() - .await - .map( - |(rate_limits, rate_limits_by_limit_id)| GetAccountRateLimitsResponse { - rate_limits: rate_limits.into(), - rate_limits_by_limit_id: Some( - rate_limits_by_limit_id - .into_iter() - .map(|(limit_id, snapshot)| (limit_id, snapshot.into())) - .collect(), - ), - }, - ); - self.outgoing.send_result(request_id, result).await; - } - - async fn send_add_credits_nudge_email( - &self, - request_id: ConnectionRequestId, - params: SendAddCreditsNudgeEmailParams, - ) { - let result = self - .send_add_credits_nudge_email_inner(params) - .await - .map(|status| SendAddCreditsNudgeEmailResponse { status }); - self.outgoing.send_result(request_id, result).await; - } - - async fn send_add_credits_nudge_email_inner( - &self, - params: SendAddCreditsNudgeEmailParams, - ) -> Result { - let Some(auth) = self.auth_manager.auth().await else { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "codex account authentication required to notify workspace owner" - .to_string(), - data: None, - }); - }; - - if !auth.uses_codex_backend() { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "chatgpt authentication required to notify workspace owner".to_string(), - data: None, - }); - } - - let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to construct backend client: {err}"), - data: None, - })?; - - match client - .send_add_credits_nudge_email(Self::backend_credit_type(params.credit_type)) - .await - { - Ok(()) => Ok(AddCreditsNudgeEmailStatus::Sent), - Err(err) if err.status().is_some_and(|status| status.as_u16() == 429) => { - Ok(AddCreditsNudgeEmailStatus::CooldownActive) - } - Err(err) => Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to notify workspace owner: {err}"), - data: None, - }), - } - } - - fn backend_credit_type(value: AddCreditsNudgeCreditType) -> BackendAddCreditsNudgeCreditType { - match value { - AddCreditsNudgeCreditType::Credits => BackendAddCreditsNudgeCreditType::Credits, - AddCreditsNudgeCreditType::UsageLimit => BackendAddCreditsNudgeCreditType::UsageLimit, - } - } - - async fn fetch_account_rate_limits( - &self, - ) -> Result< - ( - CoreRateLimitSnapshot, - HashMap, - ), - JSONRPCErrorError, - > { - let Some(auth) = self.auth_manager.auth().await else { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "codex account authentication required to read rate limits".to_string(), - data: None, - }); - }; - - if !auth.uses_codex_backend() { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "chatgpt authentication required to read rate limits".to_string(), - data: None, - }); - } - - let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to construct backend client: {err}"), - data: None, - })?; - - let snapshots = client - .get_rate_limits_many() - .await - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to fetch codex rate limits: {err}"), - data: None, - })?; - if snapshots.is_empty() { - return Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: "failed to fetch codex rate limits: no snapshots returned".to_string(), - data: None, - }); - } - - let rate_limits_by_limit_id: HashMap = snapshots - .iter() - .cloned() - .map(|snapshot| { - let limit_id = snapshot - .limit_id - .clone() - .unwrap_or_else(|| "codex".to_string()); - (limit_id, snapshot) - }) - .collect(); - - let primary = snapshots - .iter() - .find(|snapshot| snapshot.limit_id.as_deref() == Some("codex")) - .cloned() - .unwrap_or_else(|| snapshots[0].clone()); - - Ok((primary, rate_limits_by_limit_id)) - } - - async fn exec_one_off_command( - &self, - request_id: ConnectionRequestId, - params: CommandExecParams, - ) { - let result = self - .exec_one_off_command_inner(request_id.clone(), params) - .await - .map(|()| None::); - self.send_optional_result(request_id, result).await; - } - - async fn exec_one_off_command_inner( - &self, - request_id: ConnectionRequestId, - params: CommandExecParams, - ) -> Result<(), JSONRPCErrorError> { - tracing::debug!("ExecOneOffCommand params: {params:?}"); - - let request = request_id.clone(); - - if params.command.is_empty() { - return Err(invalid_request("command must not be empty")); - } - - let CommandExecParams { - command, - process_id, - tty, - stream_stdin, - stream_stdout_stderr, - output_bytes_cap, - disable_output_cap, - disable_timeout, - timeout_ms, - cwd, - env: env_overrides, - size, - sandbox_policy, - permission_profile, - } = params; - if sandbox_policy.is_some() && permission_profile.is_some() { - return Err(invalid_request( - "`permissionProfile` cannot be combined with `sandboxPolicy`", - )); - } - - if size.is_some() && !tty { - return Err(invalid_params("command/exec size requires tty: true")); - } - - if disable_output_cap && output_bytes_cap.is_some() { - return Err(invalid_params( - "command/exec cannot set both outputBytesCap and disableOutputCap", - )); - } - - if disable_timeout && timeout_ms.is_some() { - return Err(invalid_params( - "command/exec cannot set both timeoutMs and disableTimeout", - )); - } - - let cwd = cwd.map_or_else(|| self.config.cwd.clone(), |cwd| self.config.cwd.join(cwd)); - let mut env = create_env( - &self.config.permissions.shell_environment_policy, - /*thread_id*/ None, - ); - if let Some(env_overrides) = env_overrides { - for (key, value) in env_overrides { - match value { - Some(value) => { - env.insert(key, value); - } - None => { - env.remove(&key); - } - } - } - } - let timeout_ms = match timeout_ms { - Some(timeout_ms) => match u64::try_from(timeout_ms) { - Ok(timeout_ms) => Some(timeout_ms), - Err(_) => { - return Err(invalid_params(format!( - "command/exec timeoutMs must be non-negative, got {timeout_ms}" - ))); - } - }, - None => None, - }; - let managed_network_requirements_enabled = - self.config.managed_network_requirements_enabled(); - let started_network_proxy = match self.config.permissions.network.as_ref() { - Some(spec) => match spec - .start_proxy( - self.config.permissions.permission_profile.get(), - /*policy_decider*/ None, - /*blocked_request_observer*/ None, - managed_network_requirements_enabled, - NetworkProxyAuditMetadata::default(), - ) - .await - { - Ok(started) => Some(started), - Err(err) => { - return Err(internal_error(format!( - "failed to start managed network proxy: {err}" - ))); - } - }, - None => None, - }; - let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); - let output_bytes_cap = if disable_output_cap { - None - } else { - Some(output_bytes_cap.unwrap_or(DEFAULT_OUTPUT_BYTES_CAP)) - }; - let expiration = if disable_timeout { - ExecExpiration::Cancellation(CancellationToken::new()) - } else { - match timeout_ms { - Some(timeout_ms) => timeout_ms.into(), - None => ExecExpiration::DefaultTimeout, - } - }; - let capture_policy = if disable_output_cap { - ExecCapturePolicy::FullBuffer - } else { - ExecCapturePolicy::ShellTool - }; - let sandbox_cwd = if permission_profile.is_some() { - cwd.clone() - } else { - self.config.cwd.clone() - }; - let exec_params = ExecParams { - command, - cwd: cwd.clone(), - expiration, - capture_policy, - env, - network: started_network_proxy - .as_ref() - .map(codex_core::config::StartedNetworkProxy::proxy), - sandbox_permissions: SandboxPermissions::UseDefault, - windows_sandbox_level, - windows_sandbox_private_desktop: self - .config - .permissions - .windows_sandbox_private_desktop, - justification: None, - arg0: None, - }; - - let effective_permission_profile = if let Some(permission_profile) = permission_profile { - let permission_profile = - codex_protocol::models::PermissionProfile::from(permission_profile); - let (mut file_system_sandbox_policy, network_sandbox_policy) = - permission_profile.to_runtime_permissions(); - let configured_file_system_sandbox_policy = - self.config.permissions.file_system_sandbox_policy(); - Self::preserve_configured_deny_read_restrictions( - &mut file_system_sandbox_policy, - &configured_file_system_sandbox_policy, - ); - let effective_permission_profile = - codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement( - permission_profile.enforcement(), - &file_system_sandbox_policy, - network_sandbox_policy, - ); - self.config - .permissions - .permission_profile - .can_set(&effective_permission_profile) - .map_err(|err| invalid_request(format!("invalid permission profile: {err}")))?; - effective_permission_profile - } else if let Some(policy) = sandbox_policy.map(|policy| policy.to_core()) { - self.config - .permissions - .can_set_legacy_sandbox_policy(&policy, &sandbox_cwd) - .map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?; - let file_system_sandbox_policy = - codex_protocol::permissions::FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, &sandbox_cwd); - let network_sandbox_policy = - codex_protocol::permissions::NetworkSandboxPolicy::from(&policy); - let permission_profile = - codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement( - codex_protocol::models::SandboxEnforcement::from_legacy_sandbox_policy(&policy), - &file_system_sandbox_policy, - network_sandbox_policy, - ); - self.config - .permissions - .permission_profile - .can_set(&permission_profile) - .map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?; - permission_profile - } else { - self.config.permissions.permission_profile() - }; - - let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); - let outgoing = self.outgoing.clone(); - let request_for_task = request.clone(); - let started_network_proxy_for_task = started_network_proxy; - let use_legacy_landlock = self.config.features.use_legacy_landlock(); - let size = match size.map(crate::command_exec::terminal_size_from_protocol) { - Some(Ok(size)) => Some(size), - Some(Err(error)) => return Err(error), - None => None, - }; - - let exec_request = codex_core::exec::build_exec_request( - exec_params, - &effective_permission_profile, - &sandbox_cwd, - &codex_linux_sandbox_exe, - use_legacy_landlock, - ) - .map_err(|err| internal_error(format!("exec failed: {err}")))?; - self.command_exec_manager - .start(StartCommandExecParams { - outgoing, - request_id: request_for_task, - process_id, - exec_request, - started_network_proxy: started_network_proxy_for_task, - tty, - stream_stdin, - stream_stdout_stderr, - output_bytes_cap, - size, - }) - .await - } - - fn preserve_configured_deny_read_restrictions( - file_system_sandbox_policy: &mut FileSystemSandboxPolicy, - configured_file_system_sandbox_policy: &FileSystemSandboxPolicy, - ) { - file_system_sandbox_policy - .preserve_deny_read_restrictions_from(configured_file_system_sandbox_policy); - } - - async fn command_exec_write( - &self, - request_id: ConnectionRequestId, - params: CommandExecWriteParams, - ) { - let result = self - .command_exec_manager - .write(request_id.clone(), params) - .await; - self.outgoing.send_result(request_id, result).await; - } - - async fn command_exec_resize( - &self, - request_id: ConnectionRequestId, - params: CommandExecResizeParams, - ) { - let result = self - .command_exec_manager - .resize(request_id.clone(), params) - .await; - self.outgoing.send_result(request_id, result).await; - } - - async fn command_exec_terminate( - &self, - request_id: ConnectionRequestId, - params: CommandExecTerminateParams, - ) { - let result = self - .command_exec_manager - .terminate(request_id.clone(), params) - .await; - self.outgoing.send_result(request_id, result).await; - } - - async fn thread_start( - &self, - request_id: ConnectionRequestId, - params: ThreadStartParams, - app_server_client_name: Option, - app_server_client_version: Option, - request_context: RequestContext, - ) { - let ThreadStartParams { - model, - model_provider, - service_tier, - cwd, - approval_policy, - approvals_reviewer, - sandbox, - permissions, - config, - service_name, - base_instructions, - developer_instructions, - dynamic_tools, - mock_experimental_field: _mock_experimental_field, - experimental_raw_events, - personality, - ephemeral, - session_start_source, - environments, - persist_extended_history, - } = params; - if sandbox.is_some() && permissions.is_some() { - self.outgoing - .send_error( - request_id, - invalid_request("`permissions` cannot be combined with `sandbox`"), - ) - .await; - return; - } - let environment_selections = match self.parse_environment_selections(environments) { - Ok(environment_selections) => environment_selections, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - let mut typesafe_overrides = self.build_thread_config_overrides( - model, - model_provider, - service_tier, - cwd, - approval_policy, - approvals_reviewer, - sandbox, - permissions, - base_instructions, - developer_instructions, - personality, - ); - typesafe_overrides.ephemeral = ephemeral; - let listener_task_context = ListenerTaskContext { - thread_manager: Arc::clone(&self.thread_manager), - thread_state_manager: self.thread_state_manager.clone(), - outgoing: Arc::clone(&self.outgoing), - pending_thread_unloads: Arc::clone(&self.pending_thread_unloads), - analytics_events_client: self.analytics_events_client.clone(), - thread_watch_manager: self.thread_watch_manager.clone(), - thread_list_state_permit: self.thread_list_state_permit.clone(), - fallback_model_provider: self.config.model_provider_id.clone(), - codex_home: self.config.codex_home.to_path_buf(), - }; - let request_trace = request_context.request_trace(); - let config_manager = self.config_manager.clone(); - let thread_start_task = async move { - Self::thread_start_task( - listener_task_context, - config_manager, - request_id, - app_server_client_name, - app_server_client_version, - config, - typesafe_overrides, - dynamic_tools, - session_start_source, - environment_selections, - persist_extended_history, - service_name, - experimental_raw_events, - request_trace, - ) - .await; - }; - self.background_tasks - .spawn(thread_start_task.instrument(request_context.span())); - } - - pub(crate) async fn import_external_agent_session( - &self, - session: ImportedExternalAgentSession, - ) -> Result { - let ImportedExternalAgentSession { - cwd, - title, - rollout_items, - } = session; - let typesafe_overrides = self.build_thread_config_overrides( - /*model*/ None, - /*model_provider*/ None, - /*service_tier*/ None, - Some(cwd.to_string_lossy().into_owned()), - /*approval_policy*/ None, - /*approvals_reviewer*/ None, - /*sandbox*/ None, - /*permissions*/ None, - /*base_instructions*/ None, - /*developer_instructions*/ None, - /*personality*/ None, - ); - let config = self - .config_manager - .load_with_overrides(/*request_overrides*/ None, typesafe_overrides) - .await - .map_err(|err| { - internal_error(format!("failed to load imported session config: {err}")) - })?; - let environments = self - .thread_manager - .default_environment_selections(&config.cwd); - let imported_thread = self - .thread_manager - .start_thread_with_options(StartThreadOptions { - config, - initial_history: InitialHistory::Forked(rollout_items), - session_source: None, - dynamic_tools: Vec::new(), - persist_extended_history: false, - metrics_service_name: None, - parent_trace: None, - environments, - }) - .await - .map_err(|err| internal_error(format!("failed to import session: {err}")))?; - if let Some(title) = title - && let Some(name) = codex_core::util::normalize_thread_name(&title) - { - imported_thread - .thread - .submit(Op::SetThreadName { name }) - .await - .map_err(|err| internal_error(format!("failed to name imported session: {err}")))?; - } - Ok(imported_thread.thread_id) - } - - pub(crate) async fn drain_background_tasks(&self) { - self.background_tasks.close(); - if tokio::time::timeout(Duration::from_secs(10), self.background_tasks.wait()) - .await - .is_err() - { - warn!("timed out waiting for background tasks to shut down; proceeding"); - } - } - - pub(crate) async fn cancel_active_login(&self) { - let mut guard = self.active_login.lock().await; - if let Some(active_login) = guard.take() { - drop(active_login); - } - } - - pub(crate) async fn clear_all_thread_listeners(&self) { - self.thread_state_manager.clear_all_listeners().await; - } - - pub(crate) async fn shutdown_threads(&self) { - let report = self - .thread_manager - .shutdown_all_threads_bounded(Duration::from_secs(10)) - .await; - for thread_id in report.submit_failed { - warn!("failed to submit Shutdown to thread {thread_id}"); - } - for thread_id in report.timed_out { - warn!("timed out waiting for thread {thread_id} to shut down"); - } - } - - async fn request_trace_context( - &self, - request_id: &ConnectionRequestId, - ) -> Option { - self.outgoing.request_trace_context(request_id).await - } - - async fn submit_core_op( - &self, - request_id: &ConnectionRequestId, - thread: &CodexThread, - op: Op, - ) -> CodexResult { - thread - .submit_with_trace(op, self.request_trace_context(request_id).await) - .await - } - - #[allow(clippy::too_many_arguments)] - async fn thread_start_task( - listener_task_context: ListenerTaskContext, - config_manager: ConfigManager, - request_id: ConnectionRequestId, - app_server_client_name: Option, - app_server_client_version: Option, - config_overrides: Option>, - typesafe_overrides: ConfigOverrides, - dynamic_tools: Option>, - session_start_source: Option, - environments: Option>, - persist_extended_history: bool, - service_name: Option, - experimental_raw_events: bool, - request_trace: Option, - ) { - let result = async { - let requested_cwd = typesafe_overrides.cwd.clone(); - let mut config = config_manager - .load_with_overrides(config_overrides.clone(), typesafe_overrides.clone()) - .await - .map_err(|err| config_load_error(&err))?; - - // The user may have requested WorkspaceWrite or DangerFullAccess via - // the command line, though in the process of deriving the Config, it - // could be downgraded to ReadOnly (perhaps there is no sandbox - // available on Windows or the enterprise config disallows it). The cwd - // should still be considered "trusted" in this case. - let requested_permissions_trust_project = - requested_permissions_trust_project(&typesafe_overrides, config.cwd.as_path()); - let effective_permissions_trust_project = permission_profile_trusts_project( - &config.permissions.permission_profile(), - config.cwd.as_path(), - ); - - if requested_cwd.is_some() - && config.active_project.trust_level.is_none() - && (requested_permissions_trust_project || effective_permissions_trust_project) - { - let trust_target = - resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &config.cwd) - .await - .unwrap_or_else(|| config.cwd.clone()); - let current_cli_overrides = config_manager.current_cli_overrides(); - let cli_overrides_with_trust; - let cli_overrides_for_reload = - if let Err(err) = codex_core::config::set_project_trust_level( - &listener_task_context.codex_home, - trust_target.as_path(), - TrustLevel::Trusted, - ) { - warn!( - "failed to persist trusted project state for {}; continuing with in-memory trust for this thread: {err}", - trust_target.display() - ); - let mut project = toml::map::Map::new(); - project.insert( - "trust_level".to_string(), - TomlValue::String("trusted".to_string()), - ); - let mut projects = toml::map::Map::new(); - projects.insert( - project_trust_key(trust_target.as_path()), - TomlValue::Table(project), - ); - cli_overrides_with_trust = current_cli_overrides - .iter() - .cloned() - .chain(std::iter::once(( - "projects".to_string(), - TomlValue::Table(projects), - ))) - .collect::>(); - cli_overrides_with_trust.as_slice() - } else { - current_cli_overrides.as_slice() - }; - - config = config_manager - .load_with_cli_overrides( - cli_overrides_for_reload, - config_overrides, - typesafe_overrides, - /*fallback_cwd*/ None, - ) - .await - .map_err(|err| config_load_error(&err))?; - } - - let instruction_sources = Self::instruction_sources_from_config(&config).await; - let environments = environments.unwrap_or_else(|| { - listener_task_context - .thread_manager - .default_environment_selections(&config.cwd) - }); - let dynamic_tools = dynamic_tools.unwrap_or_default(); - let core_dynamic_tools = if dynamic_tools.is_empty() { - Vec::new() - } else { - validate_dynamic_tools(&dynamic_tools).map_err(invalid_request)?; - dynamic_tools - .into_iter() - .map(|tool| CoreDynamicToolSpec { - namespace: tool.namespace, - name: tool.name, - description: tool.description, - input_schema: tool.input_schema, - defer_loading: tool.defer_loading, - }) - .collect() - }; - let core_dynamic_tool_count = core_dynamic_tools.len(); - - let NewThread { - thread_id, - thread, - session_configured, - .. - } = listener_task_context - .thread_manager - .start_thread_with_options(StartThreadOptions { - config, - initial_history: match session_start_source - .unwrap_or(codex_app_server_protocol::ThreadStartSource::Startup) - { - codex_app_server_protocol::ThreadStartSource::Startup => { - InitialHistory::New - } - codex_app_server_protocol::ThreadStartSource::Clear => { - InitialHistory::Cleared - } - }, - session_source: None, - dynamic_tools: core_dynamic_tools, - persist_extended_history, - metrics_service_name: service_name, - parent_trace: request_trace, - environments, - }) - .instrument(tracing::info_span!( - "app_server.thread_start.create_thread", - otel.name = "app_server.thread_start.create_thread", - thread_start.dynamic_tool_count = core_dynamic_tool_count, - thread_start.persist_extended_history = persist_extended_history, - )) - .await - .map_err(|err| match err { - CodexErr::InvalidRequest(message) => invalid_request(message), - err => internal_error(format!("error creating thread: {err}")), - })?; - - Self::set_app_server_client_info( - thread.as_ref(), - app_server_client_name, - app_server_client_version, - ) - .await?; - - let config_snapshot = thread - .config_snapshot() - .instrument(tracing::info_span!( - "app_server.thread_start.config_snapshot", - otel.name = "app_server.thread_start.config_snapshot", - )) - .await; - let mut thread = build_thread_from_snapshot( - thread_id, - &config_snapshot, - session_configured.rollout_path.clone(), - ); - - // Auto-attach a thread listener when starting a thread. - Self::log_listener_attach_result( - Self::ensure_conversation_listener_task( - listener_task_context.clone(), - thread_id, - request_id.connection_id, - experimental_raw_events, - ) - .instrument(tracing::info_span!( - "app_server.thread_start.attach_listener", - otel.name = "app_server.thread_start.attach_listener", - thread_start.experimental_raw_events = experimental_raw_events, - )) - .await, - thread_id, - request_id.connection_id, - "thread", - ); - - listener_task_context - .thread_watch_manager - .upsert_thread_silently(thread.clone()) - .instrument(tracing::info_span!( - "app_server.thread_start.upsert_thread", - otel.name = "app_server.thread_start.upsert_thread", - )) - .await; - - thread.status = resolve_thread_status( - listener_task_context - .thread_watch_manager - .loaded_status_for_thread(&thread.id) - .instrument(tracing::info_span!( - "app_server.thread_start.resolve_status", - otel.name = "app_server.thread_start.resolve_status", - )) - .await, - /*has_in_progress_turn*/ false, - ); - - let sandbox = thread_response_sandbox_policy( - &config_snapshot.permission_profile, - config_snapshot.cwd.as_path(), - ); - let active_permission_profile = thread_response_active_permission_profile( - config_snapshot.active_permission_profile, - ); - - let response = ThreadStartResponse { - thread: thread.clone(), - model: config_snapshot.model, - model_provider: config_snapshot.model_provider_id, - service_tier: config_snapshot.service_tier, - cwd: config_snapshot.cwd, - instruction_sources, - approval_policy: config_snapshot.approval_policy.into(), - approvals_reviewer: config_snapshot.approvals_reviewer.into(), - sandbox, - permission_profile: Some(config_snapshot.permission_profile.into()), - active_permission_profile, - reasoning_effort: config_snapshot.reasoning_effort, - }; - Ok::<_, JSONRPCErrorError>((response, thread_started_notification(thread))) - } - .await; - - match result { - Ok((response, notif)) => { - listener_task_context - .outgoing - .send_response(request_id, response) - .instrument(tracing::info_span!( - "app_server.thread_start.send_response", - otel.name = "app_server.thread_start.send_response", - )) - .await; - - listener_task_context - .outgoing - .send_server_notification(ServerNotification::ThreadStarted(notif)) - .instrument(tracing::info_span!( - "app_server.thread_start.notify_started", - otel.name = "app_server.thread_start.notify_started", - )) - .await; - } - Err(error) => { - listener_task_context - .outgoing - .send_error(request_id, error) - .await; - } - } - } - - #[allow(clippy::too_many_arguments)] - fn build_thread_config_overrides( - &self, - model: Option, - model_provider: Option, - service_tier: Option>, - cwd: Option, - approval_policy: Option, - approvals_reviewer: Option, - sandbox: Option, - permissions: Option, - base_instructions: Option, - developer_instructions: Option, - personality: Option, - ) -> ConfigOverrides { - let mut overrides = ConfigOverrides { - model, - model_provider, - service_tier, - cwd: cwd.map(PathBuf::from), - approval_policy: approval_policy - .map(codex_app_server_protocol::AskForApproval::to_core), - approvals_reviewer: approvals_reviewer - .map(codex_app_server_protocol::ApprovalsReviewer::to_core), - sandbox_mode: sandbox.map(SandboxMode::to_core), - codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), - main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), - base_instructions, - developer_instructions, - personality, - ..Default::default() - }; - apply_permission_profile_selection_to_config_overrides(&mut overrides, permissions); - overrides - } - - fn parse_environment_selections( - &self, - environments: Option>, - ) -> Result>, JSONRPCErrorError> { - let environment_selections = environments.map(|environments| { - environments - .into_iter() - .map(|environment| TurnEnvironmentSelection { - environment_id: environment.environment_id, - cwd: environment.cwd, - }) - .collect::>() - }); - if let Some(environment_selections) = environment_selections.as_ref() { - self.thread_manager - .validate_environment_selections(environment_selections) - .map_err(|err| invalid_request(environment_selection_error_message(err)))?; - } - Ok(environment_selections) - } - - async fn thread_archive(&self, request_id: ConnectionRequestId, params: ThreadArchiveParams) { - let _thread_list_state_permit = match self.acquire_thread_list_state_permit().await { - Ok(permit) => permit, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - let result = self.thread_archive_response(params).await; - let archived_thread_ids = result - .as_ref() - .ok() - .map(|(_, thread_ids)| thread_ids.clone()); - self.outgoing - .send_result(request_id, result.map(|(response, _)| response)) - .await; - - if let Some(archived_thread_ids) = archived_thread_ids { - for thread_id in archived_thread_ids { - let notification = ThreadArchivedNotification { thread_id }; - self.outgoing - .send_server_notification(ServerNotification::ThreadArchived(notification)) - .await; - } - } - } - - async fn thread_archive_response( - &self, - params: ThreadArchiveParams, - ) -> Result<(ThreadArchiveResponse, Vec), JSONRPCErrorError> { - let thread_id = ThreadId::from_string(¶ms.thread_id) - .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; - - let mut thread_ids = vec![thread_id]; - if let Some(state_db_ctx) = get_state_db(&self.config).await { - let descendants = state_db_ctx - .list_thread_spawn_descendants(thread_id) - .await - .map_err(|err| { - internal_error(format!( - "failed to list spawned descendants for thread id {thread_id}: {err}" - )) - })?; - let mut seen = HashSet::from([thread_id]); - for descendant_id in descendants { - if seen.insert(descendant_id) { - thread_ids.push(descendant_id); - } - } - } - - let mut archive_thread_ids = Vec::new(); - match self - .thread_store - .read_thread(StoreReadThreadParams { - thread_id, - include_archived: false, - include_history: false, - }) - .await - { - Ok(thread) => { - if thread.archived_at.is_none() { - archive_thread_ids.push(thread_id); - } - } - Err(err) => return Err(thread_store_archive_error("archive", err)), - } - for descendant_thread_id in thread_ids.into_iter().skip(1) { - match self - .thread_store - .read_thread(StoreReadThreadParams { - thread_id: descendant_thread_id, - include_archived: true, - include_history: false, - }) - .await - { - Ok(thread) => { - if thread.archived_at.is_none() { - archive_thread_ids.push(descendant_thread_id); - } - } - Err(err) => { - warn!( - "failed to read spawned descendant thread {descendant_thread_id} while archiving {thread_id}: {err}" - ); - } - } - } - - let mut archived_thread_ids = Vec::new(); - let Some((parent_thread_id, descendant_thread_ids)) = archive_thread_ids.split_first() - else { - return Ok((ThreadArchiveResponse {}, archived_thread_ids)); - }; - - self.prepare_thread_for_archive(*parent_thread_id).await; - match self - .thread_store - .archive_thread(StoreArchiveThreadParams { - thread_id: *parent_thread_id, - }) - .await - { - Ok(()) => { - archived_thread_ids.push(parent_thread_id.to_string()); - } - Err(err) => return Err(thread_store_archive_error("archive", err)), - } - - for descendant_thread_id in descendant_thread_ids.iter().rev().copied() { - self.prepare_thread_for_archive(descendant_thread_id).await; - match self - .thread_store - .archive_thread(StoreArchiveThreadParams { - thread_id: descendant_thread_id, - }) - .await - { - Ok(()) => { - archived_thread_ids.push(descendant_thread_id.to_string()); - } - Err(err) => { - warn!( - "failed to archive spawned descendant thread {descendant_thread_id} while archiving {thread_id}: {err}" - ); - } - } - } - - Ok((ThreadArchiveResponse {}, archived_thread_ids)) - } - - async fn thread_increment_elicitation( - &self, - request_id: ConnectionRequestId, - params: ThreadIncrementElicitationParams, - ) { - let result = async { - let (_, thread) = self.load_thread(¶ms.thread_id).await?; - let count = thread - .increment_out_of_band_elicitation_count() - .await - .map_err(|err| { - internal_error(format!( - "failed to increment out-of-band elicitation counter: {err}" - )) - })?; - Ok::<_, JSONRPCErrorError>(ThreadIncrementElicitationResponse { - count, - paused: count > 0, - }) - } - .await; - self.outgoing.send_result(request_id, result).await; - } - - async fn thread_decrement_elicitation( - &self, - request_id: ConnectionRequestId, - params: ThreadDecrementElicitationParams, - ) { - let result = async { - let (_, thread) = self.load_thread(¶ms.thread_id).await?; - let count = thread - .decrement_out_of_band_elicitation_count() - .await - .map_err(|err| match err { - CodexErr::InvalidRequest(message) => invalid_request(message), - err => internal_error(format!( - "failed to decrement out-of-band elicitation counter: {err}" - )), - })?; - Ok::<_, JSONRPCErrorError>(ThreadDecrementElicitationResponse { - count, - paused: count > 0, - }) - } - .await; - self.outgoing.send_result(request_id, result).await; - } - - async fn thread_set_name(&self, request_id: ConnectionRequestId, params: ThreadSetNameParams) { - let result = self.thread_set_name_response(&request_id, params).await; - let notification = result - .as_ref() - .ok() - .and_then(|(_, notification)| notification.clone()); - self.outgoing - .send_result(request_id, result.map(|(response, _)| response)) - .await; - - if let Some(notification) = notification { - self.outgoing - .send_server_notification(ServerNotification::ThreadNameUpdated(notification)) - .await; - } - } - - async fn thread_set_name_response( - &self, - request_id: &ConnectionRequestId, - params: ThreadSetNameParams, - ) -> Result<(ThreadSetNameResponse, Option), JSONRPCErrorError> - { - let ThreadSetNameParams { thread_id, name } = params; - let thread_id = ThreadId::from_string(&thread_id) - .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; - let Some(name) = codex_core::util::normalize_thread_name(&name) else { - return Err(invalid_request("thread name must not be empty")); - }; - - let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?; - if let Ok(thread) = self.thread_manager.get_thread(thread_id).await { - self.submit_core_op(request_id, thread.as_ref(), Op::SetThreadName { name }) - .await - .map_err(|err| internal_error(format!("failed to set thread name: {err}")))?; - return Ok((ThreadSetNameResponse {}, None)); - } - - self.thread_store - .update_thread_metadata(StoreUpdateThreadMetadataParams { - thread_id, - patch: StoreThreadMetadataPatch { - name: Some(name.clone()), - ..Default::default() - }, - include_archived: false, - }) - .await - .map_err(|err| thread_store_write_error("set thread name", err))?; - - Ok(( - ThreadSetNameResponse {}, - Some(ThreadNameUpdatedNotification { - thread_id: thread_id.to_string(), - thread_name: Some(name), - }), - )) - } - - async fn thread_memory_mode_set( - &self, - request_id: ConnectionRequestId, - params: ThreadMemoryModeSetParams, - ) { - let result = self.thread_memory_mode_set_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn thread_memory_mode_set_response( - &self, - params: ThreadMemoryModeSetParams, - ) -> Result { - let ThreadMemoryModeSetParams { thread_id, mode } = params; - let thread_id = ThreadId::from_string(&thread_id) - .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; - - if let Ok(thread) = self.thread_manager.get_thread(thread_id).await { - if thread.config_snapshot().await.ephemeral { - return Err(invalid_request(format!( - "ephemeral thread does not support memory mode updates: {thread_id}" - ))); - } - - thread - .set_thread_memory_mode(mode.to_core()) - .await - .map_err(|err| { - internal_error(format!("failed to set thread memory mode: {err}")) - })?; - return Ok(ThreadMemoryModeSetResponse {}); - } - - self.thread_store - .update_thread_metadata(StoreUpdateThreadMetadataParams { - thread_id, - patch: StoreThreadMetadataPatch { - memory_mode: Some(mode.to_core()), - ..Default::default() - }, - include_archived: false, - }) - .await - .map_err(|err| thread_store_write_error("set thread memory mode", err))?; - - Ok(ThreadMemoryModeSetResponse {}) - } - - async fn memory_reset(&self, request_id: ConnectionRequestId, _params: Option<()>) { - let result = self.memory_reset_response().await; - self.outgoing.send_result(request_id, result).await; - } - - async fn memory_reset_response(&self) -> Result { - let state_db = StateRuntime::init( - self.config.sqlite_home.clone(), - self.config.model_provider_id.clone(), - ) - .await - .map_err(|err| { - internal_error(format!("failed to open state db for memory reset: {err}")) - })?; - - state_db.clear_memory_data().await.map_err(|err| { - internal_error(format!("failed to clear memory rows in state db: {err}")) - })?; - - clear_memory_roots_contents(&self.config.codex_home) - .await - .map_err(|err| { - internal_error(format!( - "failed to clear memory directories under {}: {err}", - self.config.codex_home.display() - )) - })?; - - Ok(MemoryResetResponse {}) - } - - async fn thread_metadata_update( - &self, - request_id: ConnectionRequestId, - params: ThreadMetadataUpdateParams, - ) { - let result = self.thread_metadata_update_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn thread_metadata_update_response( - &self, - params: ThreadMetadataUpdateParams, - ) -> Result { - let ThreadMetadataUpdateParams { - thread_id, - git_info, - } = params; - - let thread_uuid = ThreadId::from_string(&thread_id) - .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; - - let Some(ThreadMetadataGitInfoUpdateParams { - sha, - branch, - origin_url, - }) = git_info - else { - return Err(invalid_request("gitInfo must include at least one field")); - }; - - if sha.is_none() && branch.is_none() && origin_url.is_none() { - return Err(invalid_request("gitInfo must include at least one field")); - } - - let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?; - let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok(); - let mut state_db_ctx = loaded_thread.as_ref().and_then(|thread| thread.state_db()); - if state_db_ctx.is_none() { - state_db_ctx = get_state_db(&self.config).await; - } - let Some(state_db_ctx) = state_db_ctx else { - return Err(internal_error(format!( - "sqlite state db unavailable for thread {thread_uuid}" - ))); - }; - - self.ensure_thread_metadata_row_exists(thread_uuid, &state_db_ctx, loaded_thread.as_ref()) - .await?; - - let git_sha = Self::normalize_thread_metadata_git_field(sha, "gitInfo.sha")?; - let git_branch = Self::normalize_thread_metadata_git_field(branch, "gitInfo.branch")?; - let git_origin_url = - Self::normalize_thread_metadata_git_field(origin_url, "gitInfo.originUrl")?; - - let updated = state_db_ctx - .update_thread_git_info( - thread_uuid, - git_sha.as_ref().map(|value| value.as_deref()), - git_branch.as_ref().map(|value| value.as_deref()), - git_origin_url.as_ref().map(|value| value.as_deref()), - ) - .await - .map_err(|err| { - internal_error(format!( - "failed to update thread metadata for {thread_uuid}: {err}" - )) - })?; - if !updated { - return Err(internal_error(format!( - "thread metadata disappeared before update completed: {thread_uuid}" - ))); - } - - let Some(summary) = - read_summary_from_state_db_context_by_thread_id(Some(&state_db_ctx), thread_uuid).await - else { - return Err(internal_error(format!( - "failed to reload updated thread metadata for {thread_uuid}" - ))); - }; - - let mut thread = summary_to_thread(summary, &self.config.cwd); - self.attach_thread_name(thread_uuid, &mut thread).await; - thread.status = resolve_thread_status( - self.thread_watch_manager - .loaded_status_for_thread(&thread.id) - .await, - /*has_in_progress_turn*/ false, - ); - - Ok(ThreadMetadataUpdateResponse { thread }) - } - - fn normalize_thread_metadata_git_field( - value: Option>, - name: &str, - ) -> Result>, JSONRPCErrorError> { - match value { - Some(Some(value)) => { - let value = value.trim().to_string(); - if value.is_empty() { - return Err(invalid_request(format!("{name} must not be empty"))); - } - Ok(Some(Some(value))) - } - Some(None) => Ok(Some(None)), - None => Ok(None), - } - } - - async fn ensure_thread_metadata_row_exists( - &self, - thread_uuid: ThreadId, - state_db_ctx: &Arc, - loaded_thread: Option<&Arc>, - ) -> Result<(), JSONRPCErrorError> { - match state_db_ctx.get_thread(thread_uuid).await { - Ok(Some(_)) => return Ok(()), - Ok(None) => {} - Err(err) => { - return Err(internal_error(format!( - "failed to load thread metadata for {thread_uuid}: {err}" - ))); - } - } - - if let Some(thread) = loaded_thread { - let Some(rollout_path) = thread.rollout_path() else { - return Err(invalid_request(format!( - "ephemeral thread does not support metadata updates: {thread_uuid}" - ))); - }; - - reconcile_rollout( - Some(state_db_ctx), - rollout_path.as_path(), - self.config.model_provider_id.as_str(), - /*builder*/ None, - &[], - /*archived_only*/ None, - /*new_thread_memory_mode*/ None, - ) - .await; - - match state_db_ctx.get_thread(thread_uuid).await { - Ok(Some(_)) => return Ok(()), - Ok(None) => {} - Err(err) => { - return Err(internal_error(format!( - "failed to load reconciled thread metadata for {thread_uuid}: {err}" - ))); - } - } - - let config_snapshot = thread.config_snapshot().await; - let model_provider = config_snapshot.model_provider_id.clone(); - let mut builder = ThreadMetadataBuilder::new( - thread_uuid, - rollout_path, - Utc::now(), - config_snapshot.session_source.clone(), - ); - builder.model_provider = Some(model_provider.clone()); - builder.cwd = config_snapshot.cwd.to_path_buf(); - builder.cli_version = Some(env!("CARGO_PKG_VERSION").to_string()); - builder.sandbox_policy = config_snapshot.sandbox_policy(); - builder.approval_mode = config_snapshot.approval_policy; - let metadata = builder.build(model_provider.as_str()); - if let Err(err) = state_db_ctx.insert_thread_if_absent(&metadata).await { - return Err(internal_error(format!( - "failed to create thread metadata for {thread_uuid}: {err}" - ))); - } - return Ok(()); - } - - let rollout_path = - match find_thread_path_by_id_str(&self.config.codex_home, &thread_uuid.to_string()) - .await - { - Ok(Some(path)) => path, - Ok(None) => match find_archived_thread_path_by_id_str( - &self.config.codex_home, - &thread_uuid.to_string(), - ) - .await - { - Ok(Some(path)) => path, - Ok(None) => { - return Err(invalid_request(format!("thread not found: {thread_uuid}"))); - } - Err(err) => { - return Err(internal_error(format!( - "failed to locate archived thread id {thread_uuid}: {err}" - ))); - } - }, - Err(err) => { - return Err(internal_error(format!( - "failed to locate thread id {thread_uuid}: {err}" - ))); - } - }; - - reconcile_rollout( - Some(state_db_ctx), - rollout_path.as_path(), - self.config.model_provider_id.as_str(), - /*builder*/ None, - &[], - /*archived_only*/ None, - /*new_thread_memory_mode*/ None, - ) - .await; - - match state_db_ctx.get_thread(thread_uuid).await { - Ok(Some(_)) => Ok(()), - Ok(None) => Err(internal_error(format!( - "failed to create thread metadata from rollout for {thread_uuid}" - ))), - Err(err) => Err(internal_error(format!( - "failed to load reconciled thread metadata for {thread_uuid}: {err}" - ))), - } - } - - async fn thread_unarchive( - &self, - request_id: ConnectionRequestId, - params: ThreadUnarchiveParams, - ) { - let _thread_list_state_permit = match self.acquire_thread_list_state_permit().await { - Ok(permit) => permit, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - let result = self.thread_unarchive_response(params).await; - let notification = - result - .as_ref() - .ok() - .map(|(_, thread_id)| ThreadUnarchivedNotification { - thread_id: thread_id.clone(), - }); - self.outgoing - .send_result(request_id, result.map(|(response, _)| response)) - .await; - - if let Some(notification) = notification { - self.outgoing - .send_server_notification(ServerNotification::ThreadUnarchived(notification)) - .await; - } - } - - async fn thread_unarchive_response( - &self, - params: ThreadUnarchiveParams, - ) -> Result<(ThreadUnarchiveResponse, String), JSONRPCErrorError> { - let thread_id = ThreadId::from_string(¶ms.thread_id) - .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; - - let fallback_provider = self.config.model_provider_id.clone(); - let mut thread = self - .thread_store - .unarchive_thread(StoreArchiveThreadParams { thread_id }) - .await - .map_err(|err| thread_store_archive_error("unarchive", err)) - .and_then(|stored_thread| { - summary_from_stored_thread(stored_thread, fallback_provider.as_str()) - .map(|summary| summary_to_thread(summary, &self.config.cwd)) - .ok_or_else(|| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to read unarchived thread {thread_id}"), - data: None, - }) - })?; - - thread.status = resolve_thread_status( - self.thread_watch_manager - .loaded_status_for_thread(&thread.id) - .await, - /*has_in_progress_turn*/ false, - ); - self.attach_thread_name(thread_id, &mut thread).await; - let thread_id = thread.id.clone(); - Ok((ThreadUnarchiveResponse { thread }, thread_id)) - } - - async fn thread_rollback(&self, request_id: ConnectionRequestId, params: ThreadRollbackParams) { - let result = self - .thread_rollback_start(&request_id, params) - .await - .map(|()| None::); - self.send_optional_result(request_id, result).await; - } - - async fn thread_rollback_start( - &self, - request_id: &ConnectionRequestId, - params: ThreadRollbackParams, - ) -> Result<(), JSONRPCErrorError> { - let ThreadRollbackParams { - thread_id, - num_turns, - } = params; - - if num_turns == 0 { - return Err(invalid_request("numTurns must be >= 1")); - } - - let (thread_id, thread) = self.load_thread(&thread_id).await?; - - let request = request_id.clone(); - - let rollback_already_in_progress = { - let thread_state = self.thread_state_manager.thread_state(thread_id).await; - let mut thread_state = thread_state.lock().await; - if thread_state.pending_rollbacks.is_some() { - true - } else { - thread_state.pending_rollbacks = Some(request.clone()); - false - } - }; - if rollback_already_in_progress { - return Err(invalid_request( - "rollback already in progress for this thread", - )); - } - - if let Err(err) = self - .submit_core_op( - request_id, - thread.as_ref(), - Op::ThreadRollback { num_turns }, - ) - .await - { - // No ThreadRollback event will arrive if an error occurs. - // Clean up and reply immediately. - let thread_state = self.thread_state_manager.thread_state(thread_id).await; - thread_state.lock().await.pending_rollbacks = None; - - return Err(internal_error(format!("failed to start rollback: {err}"))); - } - Ok(()) - } - - async fn thread_compact_start( - &self, - request_id: ConnectionRequestId, - params: ThreadCompactStartParams, - ) { - let ThreadCompactStartParams { thread_id } = params; - - let result = async { - let (_, thread) = self.load_thread(&thread_id).await?; - self.submit_core_op(&request_id, thread.as_ref(), Op::Compact) - .await - .map_err(|err| internal_error(format!("failed to start compaction: {err}")))?; - Ok::<_, JSONRPCErrorError>(ThreadCompactStartResponse {}) - } - .await; - self.outgoing.send_result(request_id, result).await; - } - - async fn thread_background_terminals_clean( - &self, - request_id: ConnectionRequestId, - params: ThreadBackgroundTerminalsCleanParams, - ) { - let ThreadBackgroundTerminalsCleanParams { thread_id } = params; - - let result = async { - let (_, thread) = self.load_thread(&thread_id).await?; - self.submit_core_op(&request_id, thread.as_ref(), Op::CleanBackgroundTerminals) - .await - .map_err(|err| { - internal_error(format!("failed to clean background terminals: {err}")) - })?; - Ok::<_, JSONRPCErrorError>(ThreadBackgroundTerminalsCleanResponse {}) - } - .await; - self.outgoing.send_result(request_id, result).await; - } - - async fn thread_shell_command( - &self, - request_id: ConnectionRequestId, - params: ThreadShellCommandParams, - ) { - let result = async { - let ThreadShellCommandParams { thread_id, command } = params; - let command = command.trim().to_string(); - if command.is_empty() { - return Err(invalid_request("command must not be empty")); - } - - let (_, thread) = self.load_thread(&thread_id).await?; - self.submit_core_op( - &request_id, - thread.as_ref(), - Op::RunUserShellCommand { command }, - ) - .await - .map_err(|err| internal_error(format!("failed to start shell command: {err}")))?; - Ok::<_, JSONRPCErrorError>(ThreadShellCommandResponse {}) - } - .await; - self.outgoing.send_result(request_id, result).await; - } - - async fn thread_approve_guardian_denied_action( - &self, - request_id: ConnectionRequestId, - params: ThreadApproveGuardianDeniedActionParams, - ) { - let result = async { - let ThreadApproveGuardianDeniedActionParams { thread_id, event } = params; - let event = serde_json::from_value(event) - .map_err(|err| invalid_request(format!("invalid Guardian denial event: {err}")))?; - let (_, thread) = self.load_thread(&thread_id).await?; - - self.submit_core_op( - &request_id, - thread.as_ref(), - Op::ApproveGuardianDeniedAction { event }, - ) - .await - .map_err(|err| internal_error(format!("failed to approve Guardian denial: {err}")))?; - Ok::<_, JSONRPCErrorError>(ThreadApproveGuardianDeniedActionResponse {}) - } - .await; - self.outgoing.send_result(request_id, result).await; - } - - async fn thread_list(&self, request_id: ConnectionRequestId, params: ThreadListParams) { - let result = self.thread_list_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn thread_list_response( - &self, - params: ThreadListParams, - ) -> Result { - let ThreadListParams { - cursor, - limit, - sort_key, - sort_direction, - model_providers, - source_kinds, - archived, - cwd, - use_state_db_only, - search_term, - } = params; - let cwd_filters = normalize_thread_list_cwd_filters(cwd)?; - - let requested_page_size = limit - .map(|value| value as usize) - .unwrap_or(THREAD_LIST_DEFAULT_LIMIT) - .clamp(1, THREAD_LIST_MAX_LIMIT); - let store_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) { - ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt, - ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt, - }; - let sort_direction = sort_direction.unwrap_or(SortDirection::Desc); - let (stored_threads, next_cursor) = self - .list_threads_common( - requested_page_size, - cursor, - store_sort_key, - sort_direction, - ThreadListFilters { - model_providers, - source_kinds, - archived: archived.unwrap_or(false), - cwd_filters, - search_term, - use_state_db_only, - }, - ) - .await?; - let backwards_cursor = stored_threads.first().and_then(|thread| { - thread_backwards_cursor_for_sort_key(thread, store_sort_key, sort_direction) - }); - let mut threads = Vec::with_capacity(stored_threads.len()); - let mut status_ids = Vec::with_capacity(stored_threads.len()); - let fallback_provider = self.config.model_provider_id.clone(); - - for stored_thread in stored_threads { - let (thread, _) = thread_from_stored_thread( - stored_thread, - fallback_provider.as_str(), - &self.config.cwd, - ); - status_ids.push(thread.id.clone()); - threads.push(thread); - } - - let statuses = self - .thread_watch_manager - .loaded_statuses_for_threads(status_ids) - .await; - - let data: Vec<_> = threads - .into_iter() - .map(|mut thread| { - if let Some(status) = statuses.get(&thread.id) { - thread.status = status.clone(); - } - thread - }) - .collect(); - Ok(ThreadListResponse { - data, - next_cursor, - backwards_cursor, - }) - } - - async fn thread_loaded_list( - &self, - request_id: ConnectionRequestId, - params: ThreadLoadedListParams, - ) { - let result = self.thread_loaded_list_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn thread_loaded_list_response( - &self, - params: ThreadLoadedListParams, - ) -> Result { - let ThreadLoadedListParams { cursor, limit } = params; - let mut data: Vec = self - .thread_manager - .list_thread_ids() - .await - .into_iter() - .map(|thread_id| thread_id.to_string()) - .collect(); - - if data.is_empty() { - return Ok(ThreadLoadedListResponse { - data, - next_cursor: None, - }); - } - - data.sort(); - let total = data.len(); - let start = match cursor { - Some(cursor) => { - let cursor = match ThreadId::from_string(&cursor) { - Ok(id) => id.to_string(), - Err(_) => return Err(invalid_request(format!("invalid cursor: {cursor}"))), - }; - match data.binary_search(&cursor) { - Ok(idx) => idx + 1, - Err(idx) => idx, - } - } - None => 0, - }; - - let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; - let end = start.saturating_add(effective_limit).min(total); - let page = data[start..end].to_vec(); - let next_cursor = page.last().filter(|_| end < total).cloned(); - - Ok(ThreadLoadedListResponse { - data: page, - next_cursor, - }) - } - - async fn thread_read(&self, request_id: ConnectionRequestId, params: ThreadReadParams) { - let result = self.thread_read_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn thread_read_response( - &self, - params: ThreadReadParams, - ) -> Result { - let ThreadReadParams { - thread_id, - include_turns, - } = params; - - let thread_uuid = ThreadId::from_string(&thread_id) - .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; - - let thread = self - .read_thread_view(thread_uuid, include_turns) - .await - .map_err(thread_read_view_error)?; - Ok(ThreadReadResponse { thread }) - } - - /// Builds the API view for `thread/read` from persisted metadata plus optional live state. - async fn read_thread_view( - &self, - thread_id: ThreadId, - include_turns: bool, - ) -> Result { - let loaded_thread = self.thread_manager.get_thread(thread_id).await.ok(); - let mut thread = if include_turns { - if let Some(loaded_thread) = loaded_thread.as_ref() { - // Loaded thread with turns: use persisted metadata when it exists, - // but reconstruct turns from the live ThreadStore history. - let persisted_thread = self - .load_persisted_thread_for_read(thread_id, /*include_turns*/ false) - .await?; - self.load_live_thread_view( - thread_id, - include_turns, - loaded_thread, - persisted_thread, - ) - .await? - } else if let Some(thread) = self - .load_persisted_thread_for_read(thread_id, include_turns) - .await? - { - // Unloaded thread with turns: load metadata and history together - // from the ThreadStore. - thread - } else { - return Err(ThreadReadViewError::InvalidRequest(format!( - "thread not loaded: {thread_id}" - ))); - } - } else if let Some(thread) = self - .load_persisted_thread_for_read(thread_id, include_turns) - .await? - { - // Persisted metadata-only read: no live thread state is needed. - thread - } else if let Some(loaded_thread) = loaded_thread.as_ref() { - // Loaded metadata-only read before persistence is materialized: build - // the response from the live thread snapshot. - self.load_live_thread_view( - thread_id, - include_turns, - loaded_thread, - /*persisted_thread*/ None, - ) - .await? - } else { - return Err(ThreadReadViewError::InvalidRequest(format!( - "thread not loaded: {thread_id}" - ))); - }; - - let has_live_in_progress_turn = if let Some(loaded_thread) = loaded_thread.as_ref() { - matches!(loaded_thread.agent_status().await, AgentStatus::Running) - } else { - false - }; - - let thread_status = self - .thread_watch_manager - .loaded_status_for_thread(&thread.id) - .await; - - set_thread_status_and_interrupt_stale_turns( - &mut thread, - thread_status, - has_live_in_progress_turn, - ); - Ok(thread) - } - - async fn load_persisted_thread_for_read( - &self, - thread_id: ThreadId, - include_turns: bool, - ) -> Result, ThreadReadViewError> { - let fallback_provider = self.config.model_provider_id.as_str(); - match self - .thread_store - .read_thread(StoreReadThreadParams { - thread_id, - include_archived: true, - include_history: include_turns, - }) - .await - { - Ok(stored_thread) => { - let (mut thread, history) = - thread_from_stored_thread(stored_thread, fallback_provider, &self.config.cwd); - if include_turns && let Some(history) = history { - thread.turns = build_turns_from_rollout_items(&history.items); - } - Ok(Some(thread)) - } - Err(ThreadStoreError::InvalidRequest { message }) - if message == format!("no rollout found for thread id {thread_id}") => - { - Ok(None) - } - Err(ThreadStoreError::ThreadNotFound { - thread_id: missing_thread_id, - }) if missing_thread_id == thread_id => Ok(None), - Err(ThreadStoreError::InvalidRequest { message }) => { - Err(ThreadReadViewError::InvalidRequest(message)) - } - Err(err) => Err(ThreadReadViewError::Internal(format!( - "failed to read thread: {err}" - ))), - } - } - - /// Builds a `thread/read` view from a loaded thread plus optional persisted metadata. - async fn load_live_thread_view( - &self, - thread_id: ThreadId, - include_turns: bool, - loaded_thread: &CodexThread, - persisted_thread: Option, - ) -> Result { - let config_snapshot = loaded_thread.config_snapshot().await; - if include_turns && config_snapshot.ephemeral { - return Err(ThreadReadViewError::InvalidRequest( - "ephemeral threads do not support includeTurns".to_string(), - )); - } - let fallback_thread = - build_thread_from_loaded_snapshot(thread_id, &config_snapshot, loaded_thread); - let mut thread = if let Some(mut thread) = persisted_thread { - if thread.path.is_none() { - thread.path = fallback_thread.path.clone(); - } - thread.ephemeral = fallback_thread.ephemeral; - thread - } else { - fallback_thread - }; - self.apply_thread_read_store_fields(thread_id, &mut thread, include_turns, loaded_thread) - .await?; - Ok(thread) - } - - async fn apply_thread_read_store_fields( - &self, - thread_id: ThreadId, - thread: &mut Thread, - include_turns: bool, - loaded_thread: &CodexThread, - ) -> Result<(), ThreadReadViewError> { - self.attach_thread_name(thread_id, thread).await; - - if include_turns { - let history = loaded_thread - .load_history(/*include_archived*/ true) - .await - .map_err(|err| thread_read_history_load_error(thread_id, err))?; - thread.turns = build_turns_from_rollout_items(&history.items); - } - - Ok(()) - } - - async fn thread_turns_list( - &self, - request_id: ConnectionRequestId, - params: ThreadTurnsListParams, - ) { - let result = self.thread_turns_list_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn thread_turns_list_response( - &self, - params: ThreadTurnsListParams, - ) -> Result { - let ThreadTurnsListParams { - thread_id, - cursor, - limit, - sort_direction, - } = params; - - let thread_uuid = ThreadId::from_string(&thread_id) - .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; - - let items = self - .load_thread_turns_list_history(thread_uuid) - .await - .map_err(thread_read_view_error)?; - // This API optimizes network transfer by letting clients page through a - // thread's turns incrementally, but it still replays the entire rollout on - // every request. Rollback and compaction events can change earlier turns, so - // the server has to rebuild the full turn list until turn metadata is indexed - // separately. - let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok(); - let has_live_running_thread = match loaded_thread.as_ref() { - Some(thread) => matches!(thread.agent_status().await, AgentStatus::Running), - None => false, - }; - let active_turn = if loaded_thread.is_some() { - // Persisted history may not yet include the currently running turn. The - // app-server listener has already projected live turn events into ThreadState, - // so merge that in-memory snapshot before paginating. - let thread_state = self.thread_state_manager.thread_state(thread_uuid).await; - let state = thread_state.lock().await; - state.active_turn_snapshot() - } else { - None - }; - let turns = reconstruct_thread_turns_for_turns_list( - &items, - self.thread_watch_manager - .loaded_status_for_thread(&thread_uuid.to_string()) - .await, - has_live_running_thread, - active_turn, - ); - let page = paginate_thread_turns( - turns, - cursor.as_deref(), - limit, - sort_direction.unwrap_or(SortDirection::Desc), - )?; - Ok(ThreadTurnsListResponse { - data: page.turns, - next_cursor: page.next_cursor, - backwards_cursor: page.backwards_cursor, - }) - } - - async fn load_thread_turns_list_history( - &self, - thread_id: ThreadId, - ) -> Result, ThreadReadViewError> { - match self - .thread_store - .read_thread(StoreReadThreadParams { - thread_id, - include_archived: true, - include_history: true, - }) - .await - { - Ok(stored_thread) => { - let history = stored_thread.history.ok_or_else(|| { - ThreadReadViewError::Internal(format!( - "thread store did not return history for thread {thread_id}" - )) - })?; - return Ok(history.items); - } - Err(ThreadStoreError::InvalidRequest { message }) - if message == format!("no rollout found for thread id {thread_id}") => {} - Err(ThreadStoreError::ThreadNotFound { - thread_id: missing_thread_id, - }) if missing_thread_id == thread_id => {} - Err(ThreadStoreError::InvalidRequest { message }) => { - return Err(ThreadReadViewError::InvalidRequest(message)); - } - Err(err) => { - return Err(ThreadReadViewError::Internal(format!( - "failed to read thread: {err}" - ))); - } - } - - let thread = self - .thread_manager - .get_thread(thread_id) - .await - .map_err(|_| { - ThreadReadViewError::InvalidRequest(format!("thread not loaded: {thread_id}")) - })?; - let config_snapshot = thread.config_snapshot().await; - if config_snapshot.ephemeral { - return Err(ThreadReadViewError::InvalidRequest( - "ephemeral threads do not support thread/turns/list".to_string(), - )); - } - - thread - .load_history(/*include_archived*/ true) - .await - .map(|history| history.items) - .map_err(|err| thread_turns_list_history_load_error(thread_id, err)) - } - - pub(crate) fn thread_created_receiver(&self) -> broadcast::Receiver { - self.thread_manager.subscribe_thread_created() - } - - pub(crate) async fn connection_initialized(&self, connection_id: ConnectionId) { - self.thread_state_manager - .connection_initialized(connection_id) - .await; - } - - pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) { - self.command_exec_manager - .connection_closed(connection_id) - .await; - let thread_ids = self - .thread_state_manager - .remove_connection(connection_id) - .await; - - for thread_id in thread_ids { - if self.thread_manager.get_thread(thread_id).await.is_err() { - // Reconcile stale app-server bookkeeping when the thread has already been - // removed from the core manager. - self.finalize_thread_teardown(thread_id).await; - } - } - } - - pub(crate) fn subscribe_running_assistant_turn_count(&self) -> watch::Receiver { - self.thread_watch_manager.subscribe_running_turn_count() - } - - /// Best-effort: ensure initialized connections are subscribed to this thread. - pub(crate) async fn try_attach_thread_listener( - &self, - thread_id: ThreadId, - connection_ids: Vec, - ) { - if let Ok(thread) = self.thread_manager.get_thread(thread_id).await { - let config_snapshot = thread.config_snapshot().await; - let loaded_thread = - build_thread_from_snapshot(thread_id, &config_snapshot, thread.rollout_path()); - self.thread_watch_manager.upsert_thread(loaded_thread).await; - } - - for connection_id in connection_ids { - Self::log_listener_attach_result( - self.ensure_conversation_listener( - thread_id, - connection_id, - /*raw_events_enabled*/ false, - ) - .await, - thread_id, - connection_id, - "thread", - ); - } - } - - async fn thread_resume(&self, request_id: ConnectionRequestId, params: ThreadResumeParams) { - if let Ok(thread_id) = ThreadId::from_string(¶ms.thread_id) - && self - .pending_thread_unloads - .lock() - .await - .contains(&thread_id) - { - self.outgoing - .send_error( - request_id, - invalid_request(format!( - "thread {thread_id} is closing; retry thread/resume after the thread is closed" - )), - ) - .await; - return; - } - - if params.sandbox.is_some() && params.permissions.is_some() { - self.outgoing - .send_error( - request_id, - invalid_request("`permissions` cannot be combined with `sandbox`"), - ) - .await; - return; - } - - let _thread_list_state_permit = match self.acquire_thread_list_state_permit().await { - Ok(permit) => permit, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - match self.resume_running_thread(&request_id, ¶ms).await { - Ok(true) => return, - Ok(false) => {} - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - } - - let ThreadResumeParams { - thread_id, - history, - path, - model, - model_provider, - service_tier, - cwd, - approval_policy, - approvals_reviewer, - sandbox, - permissions, - config: mut request_overrides, - base_instructions, - developer_instructions, - personality, - exclude_turns, - persist_extended_history, - } = params; - let include_turns = !exclude_turns; - - let (thread_history, resume_source_thread) = match if let Some(history) = history { - self.resume_thread_from_history(history.as_slice()) - .await - .map(|thread_history| (thread_history, None)) - } else { - self.resume_thread_from_rollout(&thread_id, path.as_ref()) - .await - .map(|(thread_history, stored_thread)| (thread_history, Some(stored_thread))) - } { - Ok(value) => value, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - - let history_cwd = thread_history.session_cwd(); - let mut typesafe_overrides = self.build_thread_config_overrides( - model, - model_provider, - service_tier, - cwd, - approval_policy, - approvals_reviewer, - sandbox, - permissions, - base_instructions, - developer_instructions, - personality, - ); - self.load_and_apply_persisted_resume_metadata( - &thread_history, - &mut request_overrides, - &mut typesafe_overrides, - ) - .await; - - // Derive a Config using the same logic as new conversation, honoring overrides if provided. - let config = match self - .config_manager - .load_for_cwd(request_overrides, typesafe_overrides, history_cwd) - .await - { - Ok(config) => config, - Err(err) => { - let error = config_load_error(&err); - self.outgoing.send_error(request_id, error).await; - return; - } - }; - - let instruction_sources = Self::instruction_sources_from_config(&config).await; - let response_history = thread_history.clone(); - - match self - .thread_manager - .resume_thread_with_history( - config.clone(), - thread_history, - self.auth_manager.clone(), - persist_extended_history, - self.request_trace_context(&request_id).await, - ) - .await - { - Ok(NewThread { - thread_id, - thread: codex_thread, - session_configured, - .. - }) => { - let SessionConfiguredEvent { rollout_path, .. } = session_configured; - let Some(rollout_path) = rollout_path else { - let error = - internal_error(format!("rollout path missing for thread {thread_id}")); - self.outgoing.send_error(request_id, error).await; - return; - }; - // Auto-attach a thread listener when resuming a thread. - Self::log_listener_attach_result( - self.ensure_conversation_listener( - thread_id, - request_id.connection_id, - /*raw_events_enabled*/ false, - ) - .await, - thread_id, - request_id.connection_id, - "thread", - ); - - let mut thread = match self - .load_thread_from_resume_source_or_send_internal( - thread_id, - codex_thread.as_ref(), - &response_history, - rollout_path.as_path(), - resume_source_thread, - include_turns, - ) - .await - { - Ok(thread) => thread, - Err(message) => { - self.outgoing - .send_error(request_id, internal_error(message)) - .await; - return; - } - }; - - self.thread_watch_manager - .upsert_thread(thread.clone()) - .await; - - let thread_status = self - .thread_watch_manager - .loaded_status_for_thread(&thread.id) - .await; - - set_thread_status_and_interrupt_stale_turns( - &mut thread, - thread_status, - /*has_live_in_progress_turn*/ false, - ); - let config_snapshot = codex_thread.config_snapshot().await; - let sandbox = thread_response_sandbox_policy( - &config_snapshot.permission_profile, - config_snapshot.cwd.as_path(), - ); - let active_permission_profile = thread_response_active_permission_profile( - config_snapshot.active_permission_profile, - ); - - let response = ThreadResumeResponse { - thread, - model: session_configured.model, - model_provider: session_configured.model_provider_id, - service_tier: session_configured.service_tier, - cwd: session_configured.cwd, - instruction_sources, - approval_policy: session_configured.approval_policy.into(), - approvals_reviewer: session_configured.approvals_reviewer.into(), - sandbox, - permission_profile: Some(config_snapshot.permission_profile.into()), - active_permission_profile, - reasoning_effort: session_configured.reasoning_effort, - }; - - let connection_id = request_id.connection_id; - let token_usage_thread = include_turns.then(|| response.thread.clone()); - self.outgoing.send_response(request_id, response).await; - // `excludeTurns` is explicitly the cheap resume path, so avoid - // rebuilding history only to attribute a replayed usage update. - if let Some(token_usage_thread) = token_usage_thread { - let token_usage_turn_id = latest_token_usage_turn_id_from_rollout_items( - &response_history.get_rollout_items(), - token_usage_thread.turns.as_slice(), - ); - // The client needs restored usage before it starts another turn. - // Sending after the response preserves JSON-RPC request ordering while - // still filling the status line before the next turn lifecycle begins. - send_thread_token_usage_update_to_connection( - &self.outgoing, - connection_id, - thread_id, - &token_usage_thread, - codex_thread.as_ref(), - token_usage_turn_id, - ) - .await; - } - if self.config.features.enabled(Feature::Goals) { - self.emit_thread_goal_snapshot(thread_id).await; - // App-server owns resume response and snapshot ordering, so wait - // until those are sent before letting core start goal continuation. - if let Err(err) = codex_thread.continue_active_goal_if_idle().await { - tracing::warn!("failed to continue active goal after resume: {err}"); - } - } - } - Err(err) => { - let error = internal_error(format!("error resuming thread: {err}")); - self.outgoing.send_error(request_id, error).await; - } - } - } - - async fn load_and_apply_persisted_resume_metadata( - &self, - thread_history: &InitialHistory, - request_overrides: &mut Option>, - typesafe_overrides: &mut ConfigOverrides, - ) -> Option { - let InitialHistory::Resumed(resumed_history) = thread_history else { - return None; - }; - let state_db_ctx = get_state_db(&self.config).await?; - let persisted_metadata = state_db_ctx - .get_thread(resumed_history.conversation_id) - .await - .ok() - .flatten()?; - merge_persisted_resume_metadata(request_overrides, typesafe_overrides, &persisted_metadata); - Some(persisted_metadata) - } - - async fn resume_running_thread( - &self, - request_id: &ConnectionRequestId, - params: &ThreadResumeParams, - ) -> Result { - let running_thread = if params.history.is_some() { - if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id) - && self - .thread_manager - .get_thread(existing_thread_id) - .await - .is_ok() - { - return Err(invalid_request(format!( - "cannot resume thread {existing_thread_id} with history while it is already running" - ))); - } - None - } else if params.path.is_some() { - let source_thread = self - .read_stored_thread_for_resume( - ¶ms.thread_id, - params.path.as_ref(), - /*include_history*/ true, - ) - .await?; - let existing_thread_id = source_thread.thread_id; - if let Ok(existing_thread) = self.thread_manager.get_thread(existing_thread_id).await { - if let (Some(requested_path), Some(active_path)) = ( - params.path.as_ref(), - existing_thread.rollout_path().as_ref(), - ) && requested_path != active_path - { - return Err(invalid_request(format!( - "cannot resume running thread {existing_thread_id} with stale path: requested `{}`, active `{}`", - requested_path.display(), - active_path.display() - ))); - } - Some((existing_thread_id, existing_thread, source_thread)) - } else { - None - } - } else if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id) - && let Ok(existing_thread) = self.thread_manager.get_thread(existing_thread_id).await - { - let source_thread = self - .read_stored_thread_for_resume( - ¶ms.thread_id, - /*path*/ None, - /*include_history*/ true, - ) - .await?; - if source_thread.thread_id != existing_thread_id { - return Err(invalid_request(format!( - "cannot resume running thread {existing_thread_id} from source thread {}", - source_thread.thread_id - ))); - } - Some((existing_thread_id, existing_thread, source_thread)) - } else { - None - }; - - if let Some((existing_thread_id, existing_thread, source_thread)) = running_thread { - let history_items = source_thread - .history - .as_ref() - .map(|history| history.items.clone()) - .ok_or_else(|| { - internal_error(format!( - "thread {existing_thread_id} did not include persisted history" - )) - })?; - - let thread_state = self - .thread_state_manager - .thread_state(existing_thread_id) - .await; - self.ensure_listener_task_running( - existing_thread_id, - existing_thread.clone(), - thread_state.clone(), - ) - .await?; - - let config_snapshot = existing_thread.config_snapshot().await; - let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot); - if !mismatch_details.is_empty() { - tracing::warn!( - "thread/resume overrides ignored for running thread {}: {}", - existing_thread_id, - mismatch_details.join("; ") - ); - } - let mut summary_source_thread = source_thread; - summary_source_thread.history = None; - let thread_summary = match self - .stored_thread_to_api_thread( - summary_source_thread, - config_snapshot.model_provider_id.as_str(), - /*include_turns*/ false, - ) - .await - { - Ok(thread) => thread, - Err(message) => return Err(internal_error(message)), - }; - let mut config_for_instruction_sources = self.config.as_ref().clone(); - config_for_instruction_sources.cwd = config_snapshot.cwd.clone(); - let instruction_sources = - Self::instruction_sources_from_config(&config_for_instruction_sources).await; - - let listener_command_tx = { - let thread_state = thread_state.lock().await; - thread_state.listener_command_tx() - }; - let Some(listener_command_tx) = listener_command_tx else { - return Err(internal_error(format!( - "failed to enqueue running thread resume for thread {existing_thread_id}: thread listener is not running" - ))); - }; - - let emit_thread_goal_update = self.config.features.enabled(Feature::Goals); - let thread_goal_state_db = if emit_thread_goal_update { - if let Some(state_db) = existing_thread.state_db() { - Some(state_db) - } else { - open_state_db_for_direct_thread_lookup(&self.config).await - } - } else { - None - }; - - let command = crate::thread_state::ThreadListenerCommand::SendThreadResumeResponse( - Box::new(crate::thread_state::PendingThreadResumeRequest { - request_id: request_id.clone(), - history_items, - config_snapshot, - instruction_sources, - thread_summary, - emit_thread_goal_update, - thread_goal_state_db, - include_turns: !params.exclude_turns, - }), - ); - if listener_command_tx.send(command).is_err() { - return Err(internal_error(format!( - "failed to enqueue running thread resume for thread {existing_thread_id}: thread listener command channel is closed" - ))); - } - return Ok(true); - } - Ok(false) - } - - async fn resume_thread_from_history( - &self, - history: &[ResponseItem], - ) -> Result { - if history.is_empty() { - return Err(invalid_request("history must not be empty")); - } - Ok(InitialHistory::Forked( - history - .iter() - .cloned() - .map(RolloutItem::ResponseItem) - .collect(), - )) - } - - async fn resume_thread_from_rollout( - &self, - thread_id: &str, - path: Option<&PathBuf>, - ) -> Result<(InitialHistory, StoredThread), JSONRPCErrorError> { - let stored_thread = self - .read_stored_thread_for_resume(thread_id, path, /*include_history*/ true) - .await?; - let history = self - .stored_thread_to_initial_history(&stored_thread) - .await?; - Ok((history, stored_thread)) - } - - async fn read_stored_thread_for_resume( - &self, - thread_id: &str, - path: Option<&PathBuf>, - include_history: bool, - ) -> Result { - let result = if let Some(path) = path { - self.thread_store - .read_thread_by_rollout_path(StoreReadThreadByRolloutPathParams { - rollout_path: path.clone(), - include_archived: true, - include_history, - }) - .await - } else { - let existing_thread_id = match ThreadId::from_string(thread_id) { - Ok(id) => id, - Err(err) => { - return Err(invalid_request(format!("invalid thread id: {err}"))); - } - }; - let params = StoreReadThreadParams { - thread_id: existing_thread_id, - include_archived: true, - include_history, - }; - self.thread_store.read_thread(params).await - }; - - result.map_err(thread_store_resume_read_error) - } - - async fn stored_thread_to_initial_history( - &self, - stored_thread: &StoredThread, - ) -> Result { - let thread_id = stored_thread.thread_id; - let history = stored_thread - .history - .as_ref() - .map(|history| history.items.clone()) - .ok_or_else(|| { - internal_error(format!( - "thread {thread_id} did not include persisted history" - )) - })?; - Ok(InitialHistory::Resumed(ResumedHistory { - conversation_id: thread_id, - history, - rollout_path: stored_thread.rollout_path.clone(), - })) - } - - async fn stored_thread_to_api_thread( - &self, - stored_thread: StoredThread, - fallback_provider: &str, - include_turns: bool, - ) -> std::result::Result { - let (mut thread, history) = - thread_from_stored_thread(stored_thread, fallback_provider, &self.config.cwd); - if include_turns && let Some(history) = history { - populate_thread_turns_from_history( - &mut thread, - &history.items, - /*active_turn*/ None, - )?; - } - Ok(thread) - } - - async fn read_stored_thread_for_new_fork( - &self, - thread_id: ThreadId, - include_history: bool, - ) -> Result { - self.thread_store - .read_thread(StoreReadThreadParams { - thread_id, - include_archived: true, - include_history, - }) - .await - .map_err(thread_store_resume_read_error) - } - - async fn load_thread_from_resume_source_or_send_internal( - &self, - thread_id: ThreadId, - thread: &CodexThread, - thread_history: &InitialHistory, - rollout_path: &Path, - resume_source_thread: Option, - include_turns: bool, - ) -> std::result::Result { - let config_snapshot = thread.config_snapshot().await; - let thread = match thread_history { - InitialHistory::Resumed(resumed) => { - let fallback_provider = config_snapshot.model_provider_id.as_str(); - if let Some(stored_thread) = resume_source_thread { - let stored_thread = - if let Some(rollout_path) = stored_thread.rollout_path.clone() { - self.thread_store - .read_thread_by_rollout_path(StoreReadThreadByRolloutPathParams { - rollout_path, - include_archived: true, - include_history: false, - }) - .await - .unwrap_or(StoredThread { - history: None, - ..stored_thread - }) - } else { - self.thread_store - .read_thread(StoreReadThreadParams { - thread_id: stored_thread.thread_id, - include_archived: true, - include_history: false, - }) - .await - .unwrap_or(StoredThread { - history: None, - ..stored_thread - }) - }; - Ok(thread_from_stored_thread( - stored_thread, - fallback_provider, - &self.config.cwd, - ) - .0) - } else { - match self - .thread_store - .read_thread(StoreReadThreadParams { - thread_id: resumed.conversation_id, - include_archived: true, - include_history: false, - }) - .await - { - Ok(stored_thread) => Ok(thread_from_stored_thread( - stored_thread, - fallback_provider, - &self.config.cwd, - ) - .0), - Err(read_err) => { - Err(format!("failed to read thread from store: {read_err}")) - } - } - } - } - InitialHistory::Forked(items) => { - let mut thread = build_thread_from_snapshot( - thread_id, - &config_snapshot, - Some(rollout_path.into()), - ); - thread.preview = preview_from_rollout_items(items); - Ok(thread) - } - InitialHistory::New | InitialHistory::Cleared => Err(format!( - "failed to build resume response for thread {thread_id}: initial history missing" - )), - }; - let mut thread = thread?; - thread.id = thread_id.to_string(); - thread.path = Some(rollout_path.to_path_buf()); - if include_turns { - let history_items = thread_history.get_rollout_items(); - populate_thread_turns_from_history( - &mut thread, - &history_items, - /*active_turn*/ None, - )?; - } - self.attach_thread_name(thread_id, &mut thread).await; - Ok(thread) - } - - async fn attach_thread_name(&self, thread_id: ThreadId, thread: &mut Thread) { - if let Some(title) = title_from_state_db(&self.config, thread_id).await { - set_thread_name_from_title(thread, title); - } - } - - async fn thread_fork(&self, request_id: ConnectionRequestId, params: ThreadForkParams) { - let ThreadForkParams { - thread_id, - path, - model, - model_provider, - service_tier, - cwd, - approval_policy, - approvals_reviewer, - sandbox, - permissions, - config: cli_overrides, - base_instructions, - developer_instructions, - ephemeral, - exclude_turns, - persist_extended_history, - } = params; - let include_turns = !exclude_turns; - let result = async { - if sandbox.is_some() && permissions.is_some() { - return Err(invalid_request( - "`permissions` cannot be combined with `sandbox`", - )); - } - - let source_thread = self - .read_stored_thread_for_resume( - &thread_id, - path.as_ref(), - /*include_history*/ true, - ) - .await?; - let source_thread_id = source_thread.thread_id; - let history_items = source_thread - .history - .as_ref() - .map(|history| history.items.clone()) - .ok_or_else(|| { - internal_error(format!( - "thread {source_thread_id} did not include persisted history" - )) - })?; - let history_cwd = Some(source_thread.cwd.clone()); - - // Persist Windows sandbox mode. - let mut cli_overrides = cli_overrides.unwrap_or_default(); - if cfg!(windows) { - match WindowsSandboxLevel::from_config(&self.config) { - WindowsSandboxLevel::Elevated => { - cli_overrides - .insert("windows.sandbox".to_string(), serde_json::json!("elevated")); - } - WindowsSandboxLevel::RestrictedToken => { - cli_overrides.insert( - "windows.sandbox".to_string(), - serde_json::json!("unelevated"), - ); - } - WindowsSandboxLevel::Disabled => {} - } - } - let request_overrides = if cli_overrides.is_empty() { - None - } else { - Some(cli_overrides) - }; - let mut typesafe_overrides = self.build_thread_config_overrides( - model, - model_provider, - service_tier, - cwd, - approval_policy, - approvals_reviewer, - sandbox, - permissions, - base_instructions, - developer_instructions, - /*personality*/ None, - ); - typesafe_overrides.ephemeral = ephemeral.then_some(true); - // Derive a Config using the same logic as new conversation, honoring overrides if provided. - let config = self - .config_manager - .load_for_cwd(request_overrides, typesafe_overrides, history_cwd) - .await - .map_err(|err| config_load_error(&err))?; - - let fallback_model_provider = config.model_provider_id.clone(); - let instruction_sources = Self::instruction_sources_from_config(&config).await; - - let NewThread { - thread_id, - thread: forked_thread, - session_configured, - .. - } = self - .thread_manager - .fork_thread_from_history( - ForkSnapshot::Interrupted, - config, - InitialHistory::Resumed(ResumedHistory { - conversation_id: source_thread_id, - history: history_items.clone(), - rollout_path: source_thread.rollout_path.clone(), - }), - persist_extended_history, - self.request_trace_context(&request_id).await, - ) - .await - .map_err(|err| match err { - CodexErr::Io(_) | CodexErr::Json(_) => { - invalid_request(format!("failed to load thread {source_thread_id}: {err}")) - } - CodexErr::InvalidRequest(message) => invalid_request(message), - err => internal_error(format!("error forking thread: {err}")), - })?; - - // Auto-attach a conversation listener when forking a thread. - Self::log_listener_attach_result( - self.ensure_conversation_listener( - thread_id, - request_id.connection_id, - /*raw_events_enabled*/ false, - ) - .await, - thread_id, - request_id.connection_id, - "thread", - ); - - // Persistent forks materialize their own rollout immediately. Ephemeral forks stay - // pathless, so they rebuild their visible history from the copied source history instead. - let mut thread = - if let Some(fork_rollout_path) = session_configured.rollout_path.as_ref() { - let stored_thread = self - .read_stored_thread_for_new_fork(thread_id, include_turns) - .await?; - self.stored_thread_to_api_thread( - stored_thread, - fallback_model_provider.as_str(), - include_turns, - ) - .await - .map_err(|message| { - internal_error(format!( - "failed to load rollout `{}` for thread {thread_id}: {message}", - fork_rollout_path.display() - )) - })? - } else { - let config_snapshot = forked_thread.config_snapshot().await; - // forked thread names do not inherit the source thread name - let mut thread = - build_thread_from_snapshot(thread_id, &config_snapshot, /*path*/ None); - thread.preview = preview_from_rollout_items(&history_items); - thread.forked_from_id = Some(source_thread_id.to_string()); - if include_turns { - populate_thread_turns_from_history( - &mut thread, - &history_items, - /*active_turn*/ None, - ) - .map_err(internal_error)?; - } - thread - }; - - self.thread_watch_manager - .upsert_thread_silently(thread.clone()) - .await; - - thread.status = resolve_thread_status( - self.thread_watch_manager - .loaded_status_for_thread(&thread.id) - .await, - /*has_in_progress_turn*/ false, - ); - let config_snapshot = forked_thread.config_snapshot().await; - let sandbox = thread_response_sandbox_policy( - &config_snapshot.permission_profile, - config_snapshot.cwd.as_path(), - ); - let active_permission_profile = thread_response_active_permission_profile( - config_snapshot.active_permission_profile, - ); - - let response = ThreadForkResponse { - thread: thread.clone(), - model: session_configured.model, - model_provider: session_configured.model_provider_id, - service_tier: session_configured.service_tier, - cwd: session_configured.cwd, - instruction_sources, - approval_policy: session_configured.approval_policy.into(), - approvals_reviewer: session_configured.approvals_reviewer.into(), - sandbox, - permission_profile: Some(config_snapshot.permission_profile.into()), - active_permission_profile, - reasoning_effort: session_configured.reasoning_effort, - }; - - Ok::<_, JSONRPCErrorError>(( - response, - thread_id, - forked_thread, - history_items, - thread_started_notification(thread), - )) - } - .await; - - let (response, thread_id, forked_thread, history_items, notif) = match result { - Ok(value) => value, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - let connection_id = request_id.connection_id; - let token_usage_thread = include_turns.then(|| response.thread.clone()); - self.outgoing.send_response(request_id, response).await; - // `excludeTurns` is the cheap fork path, so skip restored usage replay - // instead of rebuilding history only to attribute a historical update. - if let Some(token_usage_thread) = token_usage_thread { - let token_usage_turn_id = if let Some(turn_id) = - latest_token_usage_turn_id_for_thread_path(&token_usage_thread).await - { - Some(turn_id) - } else { - latest_token_usage_turn_id_from_rollout_items( - &history_items, - token_usage_thread.turns.as_slice(), - ) - }; - // Mirror the resume contract for forks: the new thread is usable as soon - // as the response arrives, so restored usage must follow immediately. - send_thread_token_usage_update_to_connection( - &self.outgoing, - connection_id, - thread_id, - &token_usage_thread, - forked_thread.as_ref(), - token_usage_turn_id, - ) - .await; - } - - self.outgoing - .send_server_notification(ServerNotification::ThreadStarted(notif)) - .await; - } - - async fn get_thread_summary( - &self, - request_id: ConnectionRequestId, - params: GetConversationSummaryParams, - ) { - let result = self.get_thread_summary_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn get_thread_summary_response( - &self, - params: GetConversationSummaryParams, - ) -> Result { - let fallback_provider = self.config.model_provider_id.as_str(); - let read_result = match params { - GetConversationSummaryParams::ThreadId { conversation_id } => self - .thread_store - .read_thread(StoreReadThreadParams { - thread_id: conversation_id, - include_archived: true, - include_history: false, - }) - .await - .map_err(|err| conversation_summary_thread_id_read_error(conversation_id, err)), - GetConversationSummaryParams::RolloutPath { rollout_path } => { - let Some(local_thread_store) = self - .thread_store - .as_any() - .downcast_ref::() - else { - return Err(invalid_request( - "rollout path queries are only supported with the local thread store", - )); - }; - - local_thread_store - .read_thread_by_rollout_path( - rollout_path.clone(), - /*include_archived*/ true, - /*include_history*/ false, - ) - .await - .map_err(|err| conversation_summary_rollout_path_read_error(&rollout_path, err)) - } - }; - - let stored_thread = read_result?; - let summary = - summary_from_stored_thread(stored_thread, fallback_provider).ok_or_else(|| { - internal_error( - "failed to load conversation summary: thread is missing rollout path", - ) - })?; - Ok(GetConversationSummaryResponse { summary }) - } - - async fn list_threads_common( - &self, - requested_page_size: usize, - cursor: Option, - sort_key: StoreThreadSortKey, - sort_direction: SortDirection, - filters: ThreadListFilters, - ) -> Result<(Vec, Option), JSONRPCErrorError> { - let ThreadListFilters { - model_providers, - source_kinds, - archived, - cwd_filters, - search_term, - use_state_db_only, - } = filters; - let mut cursor_obj = cursor; - let mut last_cursor = cursor_obj.clone(); - let mut remaining = requested_page_size; - let mut items = Vec::with_capacity(requested_page_size); - let mut next_cursor: Option = None; - - let model_provider_filter = match model_providers { - Some(providers) => { - if providers.is_empty() { - None - } else { - Some(providers) - } - } - None => Some(vec![self.config.model_provider_id.clone()]), - }; - let (allowed_sources_vec, source_kind_filter) = compute_source_filters(source_kinds); - let allowed_sources = allowed_sources_vec.as_slice(); - let store_sort_direction = match sort_direction { - SortDirection::Asc => StoreSortDirection::Asc, - SortDirection::Desc => StoreSortDirection::Desc, - }; - - while remaining > 0 { - let page_size = remaining.min(THREAD_LIST_MAX_LIMIT); - let page = self - .thread_store - .list_threads(StoreListThreadsParams { - page_size, - cursor: cursor_obj.clone(), - sort_key, - sort_direction: store_sort_direction, - allowed_sources: allowed_sources.to_vec(), - model_providers: model_provider_filter.clone(), - cwd_filters: cwd_filters.clone(), - archived, - search_term: search_term.clone(), - use_state_db_only, - }) - .await - .map_err(thread_store_list_error)?; - - let mut filtered = Vec::with_capacity(page.items.len()); - for it in page.items { - let source = with_thread_spawn_agent_metadata( - it.source.clone(), - it.agent_nickname.clone(), - it.agent_role.clone(), - ); - if source_kind_filter - .as_ref() - .is_none_or(|filter| source_kind_matches(&source, filter)) - && cwd_filters.as_ref().is_none_or(|expected_cwds| { - expected_cwds.iter().any(|expected_cwd| { - path_utils::paths_match_after_normalization(&it.cwd, expected_cwd) - }) - }) - { - filtered.push(it); - if filtered.len() >= remaining { - break; - } - } - } - items.extend(filtered); - remaining = requested_page_size.saturating_sub(items.len()); - - next_cursor = page.next_cursor; - if remaining == 0 { - break; - } - - let Some(cursor_val) = next_cursor.clone() else { - break; - }; - // Break if our pagination would reuse the same cursor again; this avoids - // an infinite loop when filtering drops everything on the page. - if last_cursor.as_ref() == Some(&cursor_val) { - next_cursor = None; - break; - } - last_cursor = Some(cursor_val.clone()); - cursor_obj = Some(cursor_val); - } - - Ok((items, next_cursor)) - } - - async fn list_models( - outgoing: Arc, - thread_manager: Arc, - request_id: ConnectionRequestId, - params: ModelListParams, - ) { - let result = async { - let ModelListParams { - limit, - cursor, - include_hidden, - } = params; - let models = supported_models(thread_manager, include_hidden.unwrap_or(false)).await; - let total = models.len(); - - if total == 0 { - return Ok(ModelListResponse { - data: Vec::new(), - next_cursor: None, - }); - } - - let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; - let effective_limit = effective_limit.min(total); - let start = match cursor { - Some(cursor) => cursor - .parse::() - .map_err(|_| invalid_request(format!("invalid cursor: {cursor}")))?, - None => 0, - }; - - if start > total { - return Err(invalid_request(format!( - "cursor {start} exceeds total models {total}" - ))); - } - - let end = start.saturating_add(effective_limit).min(total); - let items = models[start..end].to_vec(); - let next_cursor = if end < total { - Some(end.to_string()) - } else { - None - }; - Ok::<_, JSONRPCErrorError>(ModelListResponse { - data: items, - next_cursor, - }) - } - .await; - outgoing.send_result(request_id, result).await; - } - - async fn list_collaboration_modes( - outgoing: Arc, - thread_manager: Arc, - request_id: ConnectionRequestId, - params: CollaborationModeListParams, - ) { - let CollaborationModeListParams {} = params; - let items = thread_manager - .list_collaboration_modes() - .into_iter() - .map(Into::into) - .collect(); - let response = CollaborationModeListResponse { data: items }; - outgoing.send_response(request_id, response).await; - } - - async fn experimental_feature_list( - &self, - request_id: ConnectionRequestId, - params: ExperimentalFeatureListParams, - ) { - let result = self.experimental_feature_list_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn experimental_feature_list_response( - &self, - params: ExperimentalFeatureListParams, - ) -> Result { - let ExperimentalFeatureListParams { cursor, limit } = params; - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; - let auth = self.auth_manager.auth().await; - let workspace_codex_plugins_enabled = self - .workspace_codex_plugins_enabled(&config, auth.as_ref()) - .await; - - let data = FEATURES - .iter() - .map(|spec| { - let (stage, display_name, description, announcement) = match spec.stage { - Stage::Experimental { - name, - menu_description, - announcement, - } => ( - ApiExperimentalFeatureStage::Beta, - Some(name.to_string()), - Some(menu_description.to_string()), - Some(announcement.to_string()), - ), - Stage::UnderDevelopment => ( - ApiExperimentalFeatureStage::UnderDevelopment, - None, - None, - None, - ), - Stage::Stable => (ApiExperimentalFeatureStage::Stable, None, None, None), - Stage::Deprecated => { - (ApiExperimentalFeatureStage::Deprecated, None, None, None) - } - Stage::Removed => (ApiExperimentalFeatureStage::Removed, None, None, None), - }; - - ApiExperimentalFeature { - name: spec.key.to_string(), - stage, - display_name, - description, - announcement, - enabled: config.features.enabled(spec.id) - && (workspace_codex_plugins_enabled - || !matches!(spec.id, Feature::Apps | Feature::Plugins)), - default_enabled: spec.default_enabled, - } - }) - .collect::>(); - - let total = data.len(); - if total == 0 { - return Ok(ExperimentalFeatureListResponse { - data: Vec::new(), - next_cursor: None, - }); - } - - // Clamp to 1 so limit=0 cannot return a non-advancing page. - let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; - let effective_limit = effective_limit.min(total); - let start = match cursor { - Some(cursor) => match cursor.parse::() { - Ok(idx) => idx, - Err(_) => return Err(invalid_request(format!("invalid cursor: {cursor}"))), - }, - None => 0, - }; - - if start > total { - return Err(invalid_request(format!( - "cursor {start} exceeds total feature flags {total}" - ))); - } - - let end = start.saturating_add(effective_limit).min(total); - let data = data[start..end].to_vec(); - let next_cursor = if end < total { - Some(end.to_string()) - } else { - None - }; - - Ok(ExperimentalFeatureListResponse { data, next_cursor }) - } - - async fn mock_experimental_method( - &self, - request_id: ConnectionRequestId, - params: MockExperimentalMethodParams, - ) { - let MockExperimentalMethodParams { value } = params; - let response = MockExperimentalMethodResponse { echoed: value }; - self.outgoing.send_response(request_id, response).await; - } - - async fn mcp_server_refresh(&self, request_id: ConnectionRequestId, _params: Option<()>) { - let result = async { - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; - Self::queue_mcp_server_refresh_for_config(&self.thread_manager, &config).await?; - Ok::<_, JSONRPCErrorError>(McpServerRefreshResponse {}) - } - .await; - self.outgoing.send_result(request_id, result).await; - } - - async fn queue_mcp_server_refresh_for_config( - thread_manager: &Arc, - config: &Config, - ) -> Result<(), JSONRPCErrorError> { - let configured_servers = thread_manager - .mcp_manager() - .configured_servers(config) - .await; - let mcp_servers = match serde_json::to_value(configured_servers) { - Ok(value) => value, - Err(err) => { - return Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to serialize MCP servers: {err}"), - data: None, - }); - } - }; - - let mcp_oauth_credentials_store_mode = - match serde_json::to_value(config.mcp_oauth_credentials_store_mode) { - Ok(value) => value, - Err(err) => { - return Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!( - "failed to serialize MCP OAuth credentials store mode: {err}" - ), - data: None, - }); - } - }; - - let refresh_config = McpServerRefreshConfig { - mcp_servers, - mcp_oauth_credentials_store_mode, - }; - - // Refresh requests are queued per thread; each thread rebuilds MCP connections on its next - // active turn to avoid work for threads that never resume. - thread_manager.refresh_mcp_servers(refresh_config).await; - Ok(()) - } - - async fn mcp_server_oauth_login( - &self, - request_id: ConnectionRequestId, - params: McpServerOauthLoginParams, - ) { - let result = self.mcp_server_oauth_login_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn mcp_server_oauth_login_response( - &self, - params: McpServerOauthLoginParams, - ) -> Result { - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; - let McpServerOauthLoginParams { - name, - scopes, - timeout_secs, - } = params; - - let configured_servers = self - .thread_manager - .mcp_manager() - .configured_servers(&config) - .await; - let Some(server) = configured_servers.get(&name) else { - return Err(invalid_request(format!( - "No MCP server named '{name}' found." - ))); - }; - - let (url, http_headers, env_http_headers) = match &server.transport { - McpServerTransportConfig::StreamableHttp { - url, - http_headers, - env_http_headers, - .. - } => (url.clone(), http_headers.clone(), env_http_headers.clone()), - _ => { - return Err(invalid_request( - "OAuth login is only supported for streamable HTTP servers.", - )); - } - }; - - let discovered_scopes = if scopes.is_none() && server.scopes.is_none() { - discover_supported_scopes(&server.transport).await - } else { - None - }; - let resolved_scopes = - resolve_oauth_scopes(scopes, server.scopes.clone(), discovered_scopes); - - let handle = perform_oauth_login_return_url( - &name, - &url, - config.mcp_oauth_credentials_store_mode, - http_headers, - env_http_headers, - &resolved_scopes.scopes, - server.oauth_resource.as_deref(), - timeout_secs, - config.mcp_oauth_callback_port, - config.mcp_oauth_callback_url.as_deref(), - ) - .await - .map_err(|err| internal_error(format!("failed to login to MCP server '{name}': {err}")))?; - let authorization_url = handle.authorization_url().to_string(); - let notification_name = name.clone(); - let outgoing = Arc::clone(&self.outgoing); - - tokio::spawn(async move { - let (success, error) = match handle.wait().await { - Ok(()) => (true, None), - Err(err) => (false, Some(err.to_string())), - }; - - let notification = ServerNotification::McpServerOauthLoginCompleted( - McpServerOauthLoginCompletedNotification { - name: notification_name, - success, - error, - }, - ); - outgoing.send_server_notification(notification).await; - }); - - Ok(McpServerOauthLoginResponse { authorization_url }) - } - - async fn list_mcp_server_status( - &self, - request_id: ConnectionRequestId, - params: ListMcpServerStatusParams, - ) { - let request = request_id.clone(); - - let outgoing = Arc::clone(&self.outgoing); - let config = match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => config, - Err(error) => { - self.outgoing.send_error(request, error).await; - return; - } - }; - let mcp_config = config - .to_mcp_config(self.thread_manager.plugins_manager().as_ref()) - .await; - let auth = self.auth_manager.auth().await; - let environment_manager = self.thread_manager.environment_manager(); - let runtime_environment = match environment_manager.default_environment() { - Some(environment) => { - // Status listing has no turn cwd. This fallback is used only - // by executor-backed stdio MCPs whose config omits `cwd`. - McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()) - } - None => McpRuntimeEnvironment::new( - environment_manager.local_environment(), - config.cwd.to_path_buf(), - ), - }; - - tokio::spawn(async move { - Self::list_mcp_server_status_task( - outgoing, - request, - params, - config, - mcp_config, - auth, - runtime_environment, - ) - .await; - }); - } - - async fn list_mcp_server_status_task( - outgoing: Arc, - request_id: ConnectionRequestId, - params: ListMcpServerStatusParams, - config: Config, - mcp_config: codex_mcp::McpConfig, - auth: Option, - runtime_environment: McpRuntimeEnvironment, - ) { - let result = Self::list_mcp_server_status_response( - request_id.request_id.to_string(), - params, - config, - mcp_config, - auth, - runtime_environment, - ) - .await; - outgoing.send_result(request_id, result).await; - } - - async fn list_mcp_server_status_response( - request_id: String, - params: ListMcpServerStatusParams, - config: Config, - mcp_config: codex_mcp::McpConfig, - auth: Option, - runtime_environment: McpRuntimeEnvironment, - ) -> Result { - let detail = match params.detail.unwrap_or(McpServerStatusDetail::Full) { - McpServerStatusDetail::Full => McpSnapshotDetail::Full, - McpServerStatusDetail::ToolsAndAuthOnly => McpSnapshotDetail::ToolsAndAuthOnly, - }; - - let snapshot = collect_mcp_server_status_snapshot_with_detail( - &mcp_config, - auth.as_ref(), - request_id, - runtime_environment, - detail, - ) - .await; - - let effective_servers = effective_mcp_servers(&mcp_config, auth.as_ref()); - let McpServerStatusSnapshot { - tools_by_server, - resources, - resource_templates, - auth_statuses, - } = snapshot; - - let mut server_names: Vec = config - .mcp_servers - .keys() - .cloned() - // Include built-in/plugin MCP servers that are present in the - // effective runtime config even when they are not user-declared in - // `config.mcp_servers`. - .chain(effective_servers.keys().cloned()) - .chain(auth_statuses.keys().cloned()) - .chain(resources.keys().cloned()) - .chain(resource_templates.keys().cloned()) - .collect(); - server_names.sort(); - server_names.dedup(); - - let total = server_names.len(); - let limit = params.limit.unwrap_or(total as u32).max(1) as usize; - let effective_limit = limit.min(total); - let start = match params.cursor { - Some(cursor) => match cursor.parse::() { - Ok(idx) => idx, - Err(_) => return Err(invalid_request(format!("invalid cursor: {cursor}"))), - }, - None => 0, - }; - - if start > total { - return Err(invalid_request(format!( - "cursor {start} exceeds total MCP servers {total}" - ))); - } - - let end = start.saturating_add(effective_limit).min(total); - - let data: Vec = server_names[start..end] - .iter() - .map(|name| McpServerStatus { - name: name.clone(), - tools: tools_by_server.get(name).cloned().unwrap_or_default(), - resources: resources.get(name).cloned().unwrap_or_default(), - resource_templates: resource_templates.get(name).cloned().unwrap_or_default(), - auth_status: auth_statuses - .get(name) - .cloned() - .unwrap_or(CoreMcpAuthStatus::Unsupported) - .into(), - }) - .collect(); - - let next_cursor = if end < total { - Some(end.to_string()) - } else { - None - }; - - Ok(ListMcpServerStatusResponse { data, next_cursor }) - } - - async fn read_mcp_resource( - &self, - request_id: ConnectionRequestId, - params: McpResourceReadParams, - ) { - let outgoing = Arc::clone(&self.outgoing); - let McpResourceReadParams { - thread_id, - server, - uri, - } = params; - - if let Some(thread_id) = thread_id { - let (_, thread) = match self.load_thread(&thread_id).await { - Ok(thread) => thread, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - - tokio::spawn(async move { - let result = thread.read_mcp_resource(&server, &uri).await; - Self::send_mcp_resource_read_response(outgoing, request_id, result).await; - }); - return; - } - - let config = match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => config, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - let mcp_config = config - .to_mcp_config(self.thread_manager.plugins_manager().as_ref()) - .await; - let auth = self.auth_manager.auth().await; - let runtime_environment = { - let environment_manager = self.thread_manager.environment_manager(); - let environment = environment_manager - .default_environment() - .unwrap_or_else(|| environment_manager.local_environment()); - // Resource reads without a thread have no turn cwd. This fallback - // is used only by executor-backed stdio MCPs whose config omits `cwd`. - McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()) - }; - - tokio::spawn(async move { - let result = match read_mcp_resource_without_thread( - &mcp_config, - auth.as_ref(), - runtime_environment, - &server, - &uri, - ) - .await - { - Ok(result) => serde_json::to_value(result).map_err(anyhow::Error::from), - Err(error) => Err(error), - }; - Self::send_mcp_resource_read_response(outgoing, request_id, result).await; - }); - } - - async fn send_mcp_resource_read_response( - outgoing: Arc, - request_id: ConnectionRequestId, - result: anyhow::Result, - ) { - let result = result - .map_err(|error| internal_error(format!("{error:#}"))) - .and_then(|result| { - serde_json::from_value::(result).map_err(|error| { - internal_error(format!( - "failed to deserialize MCP resource read response: {error}" - )) - }) - }); - outgoing.send_result(request_id, result).await; - } - - async fn call_mcp_server_tool( - &self, - request_id: ConnectionRequestId, - params: McpServerToolCallParams, - ) { - let outgoing = Arc::clone(&self.outgoing); - let thread_id = params.thread_id.clone(); - let (_, thread) = match self.load_thread(&thread_id).await { - Ok(thread) => thread, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - let meta = with_mcp_tool_call_thread_id_meta(params.meta, &thread_id); - - tokio::spawn(async move { - let result = thread - .call_mcp_tool(¶ms.server, ¶ms.tool, params.arguments, meta) - .await - .map(McpServerToolCallResponse::from) - .map_err(|error| internal_error(format!("{error:#}"))); - outgoing.send_result(request_id, result).await; - }); - } - - async fn send_optional_result( - &self, - request_id: ConnectionRequestId, - result: Result, JSONRPCErrorError>, - ) where - T: Into, - { - match result { - Ok(Some(response)) => self.outgoing.send_response(request_id, response).await, - Ok(None) => {} - Err(error) => { - self.outgoing.send_error(request_id, error).await; - } - } - } - - fn input_too_large_error(actual_chars: usize) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INVALID_PARAMS_ERROR_CODE, - message: format!( - "Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters." - ), - data: Some(serde_json::json!({ - "input_error_code": INPUT_TOO_LARGE_ERROR_CODE, - "max_chars": MAX_USER_INPUT_TEXT_CHARS, - "actual_chars": actual_chars, - })), - } - } - - fn validate_v2_input_limit(items: &[V2UserInput]) -> Result<(), JSONRPCErrorError> { - let actual_chars: usize = items.iter().map(V2UserInput::text_char_count).sum(); - if actual_chars > MAX_USER_INPUT_TEXT_CHARS { - return Err(Self::input_too_large_error(actual_chars)); - } - Ok(()) - } - - async fn wait_for_thread_shutdown(thread: &Arc) -> ThreadShutdownResult { - match tokio::time::timeout(Duration::from_secs(10), thread.shutdown_and_wait()).await { - Ok(Ok(())) => ThreadShutdownResult::Complete, - Ok(Err(_)) => ThreadShutdownResult::SubmitFailed, - Err(_) => ThreadShutdownResult::TimedOut, - } - } - - async fn finalize_thread_teardown(&self, thread_id: ThreadId) { - self.pending_thread_unloads.lock().await.remove(&thread_id); - self.outgoing - .cancel_requests_for_thread(thread_id, /*error*/ None) - .await; - self.thread_state_manager - .remove_thread_state(thread_id) - .await; - self.thread_watch_manager - .remove_thread(&thread_id.to_string()) - .await; - } - - async fn unload_thread_without_subscribers( - thread_manager: Arc, - outgoing: Arc, - pending_thread_unloads: Arc>>, - thread_state_manager: ThreadStateManager, - thread_watch_manager: ThreadWatchManager, - thread_id: ThreadId, - thread: Arc, - ) { - info!("thread {thread_id} has no subscribers and is idle; shutting down"); - - // Any pending app-server -> client requests for this thread can no longer be - // answered; cancel their callbacks before shutdown/unload. - outgoing - .cancel_requests_for_thread(thread_id, /*error*/ None) - .await; - thread_state_manager.remove_thread_state(thread_id).await; - - tokio::spawn(async move { - match Self::wait_for_thread_shutdown(&thread).await { - ThreadShutdownResult::Complete => { - if thread_manager.remove_thread(&thread_id).await.is_none() { - info!("thread {thread_id} was already removed before teardown finalized"); - thread_watch_manager - .remove_thread(&thread_id.to_string()) - .await; - pending_thread_unloads.lock().await.remove(&thread_id); - return; - } - thread_watch_manager - .remove_thread(&thread_id.to_string()) - .await; - let notification = ThreadClosedNotification { - thread_id: thread_id.to_string(), - }; - outgoing - .send_server_notification(ServerNotification::ThreadClosed(notification)) - .await; - pending_thread_unloads.lock().await.remove(&thread_id); - } - ThreadShutdownResult::SubmitFailed => { - pending_thread_unloads.lock().await.remove(&thread_id); - warn!("failed to submit Shutdown to thread {thread_id}"); - } - ThreadShutdownResult::TimedOut => { - pending_thread_unloads.lock().await.remove(&thread_id); - warn!("thread {thread_id} shutdown timed out; leaving thread loaded"); - } - } - }); - } - - async fn thread_unsubscribe( - &self, - request_id: ConnectionRequestId, - params: ThreadUnsubscribeParams, - ) { - let result = self - .thread_unsubscribe_response(params, request_id.connection_id) - .await; - self.outgoing.send_result(request_id, result).await; - } - - async fn thread_unsubscribe_response( - &self, - params: ThreadUnsubscribeParams, - connection_id: ConnectionId, - ) -> Result { - let thread_id = ThreadId::from_string(¶ms.thread_id) - .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; - - if self.thread_manager.get_thread(thread_id).await.is_err() { - // Reconcile stale app-server bookkeeping when the thread has already been - // removed from the core manager. This keeps loaded-status/subscription state - // consistent with the source of truth before reporting NotLoaded. - self.finalize_thread_teardown(thread_id).await; - return Ok(ThreadUnsubscribeResponse { - status: ThreadUnsubscribeStatus::NotLoaded, - }); - }; - - let was_subscribed = self - .thread_state_manager - .unsubscribe_connection_from_thread(thread_id, connection_id) - .await; - - let status = if was_subscribed { - ThreadUnsubscribeStatus::Unsubscribed - } else { - ThreadUnsubscribeStatus::NotSubscribed - }; - Ok(ThreadUnsubscribeResponse { status }) - } - - async fn prepare_thread_for_archive(&self, thread_id: ThreadId) { - // If the thread is active, request shutdown and wait briefly. - let removed_conversation = self.thread_manager.remove_thread(&thread_id).await; - if let Some(conversation) = removed_conversation { - info!("thread {thread_id} was active; shutting down"); - match Self::wait_for_thread_shutdown(&conversation).await { - ThreadShutdownResult::Complete => {} - ThreadShutdownResult::SubmitFailed => { - error!( - "failed to submit Shutdown to thread {thread_id}; proceeding with archive" - ); - } - ThreadShutdownResult::TimedOut => { - warn!("thread {thread_id} shutdown timed out; proceeding with archive"); - } - } - } - self.finalize_thread_teardown(thread_id).await; - } - - async fn apps_list(&self, request_id: ConnectionRequestId, params: AppsListParams) { - let mut config = match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => config, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - - if let Some(thread_id) = params.thread_id.as_deref() { - let (_, thread) = match self.load_thread(thread_id).await { - Ok(result) => result, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - - let _ = config - .features - .set_enabled(Feature::Apps, thread.enabled(Feature::Apps)); - } - - let auth = self.auth_manager.auth().await; - if !config - .features - .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend)) - { - self.outgoing - .send_response( - request_id, - AppsListResponse { - data: Vec::new(), - next_cursor: None, - }, - ) - .await; - return; - } - - if !self - .workspace_codex_plugins_enabled(&config, auth.as_ref()) - .await - { - self.outgoing - .send_response( - request_id, - AppsListResponse { - data: Vec::new(), - next_cursor: None, - }, - ) - .await; - return; - } - - let request = request_id.clone(); - let outgoing = Arc::clone(&self.outgoing); - let environment_manager = self.thread_manager.environment_manager(); - tokio::spawn(async move { - Self::apps_list_task(outgoing, request, params, config, environment_manager).await; - }); - } - - async fn apps_list_task( - outgoing: Arc, - request_id: ConnectionRequestId, - params: AppsListParams, - config: Config, - environment_manager: Arc, - ) { - let result = Self::apps_list_response(&outgoing, params, config, environment_manager).await; - outgoing.send_result(request_id, result).await; - } - - async fn apps_list_response( - outgoing: &Arc, - params: AppsListParams, - config: Config, - environment_manager: Arc, - ) -> Result { - let AppsListParams { - cursor, - limit, - thread_id: _, - force_refetch, - } = params; - let start = match cursor { - Some(cursor) => match cursor.parse::() { - Ok(idx) => idx, - Err(_) => return Err(invalid_request(format!("invalid cursor: {cursor}"))), - }, - None => 0, - }; - - let (mut accessible_connectors, mut all_connectors) = tokio::join!( - connectors::list_cached_accessible_connectors_from_mcp_tools(&config), - connectors::list_cached_all_connectors(&config) - ); - let cached_all_connectors = all_connectors.clone(); - - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); - - let accessible_config = config.clone(); - let accessible_tx = tx.clone(); - tokio::spawn(async move { - let result = - connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( - &accessible_config, - force_refetch, - &environment_manager, - ) - .await - .map(|status| status.connectors) - .map_err(|err| format!("failed to load accessible apps: {err}")); - let _ = accessible_tx.send(AppListLoadResult::Accessible(result)); - }); - - let all_config = config.clone(); - tokio::spawn(async move { - let result = connectors::list_all_connectors_with_options(&all_config, force_refetch) - .await - .map_err(|err| format!("failed to list apps: {err}")); - let _ = tx.send(AppListLoadResult::Directory(result)); - }); - - let app_list_deadline = tokio::time::Instant::now() + APP_LIST_LOAD_TIMEOUT; - let mut accessible_loaded = false; - let mut all_loaded = false; - let mut last_notified_apps = None; - - if accessible_connectors.is_some() || all_connectors.is_some() { - let merged = connectors::with_app_enabled_state( - apps_list_helpers::merge_loaded_apps( - all_connectors.as_deref(), - accessible_connectors.as_deref(), - ), - &config, - ); - if apps_list_helpers::should_send_app_list_updated_notification( - merged.as_slice(), - accessible_loaded, - all_loaded, - ) { - apps_list_helpers::send_app_list_updated_notification(outgoing, merged.clone()) - .await; - last_notified_apps = Some(merged); - } - } - - loop { - let result = match tokio::time::timeout_at(app_list_deadline, rx.recv()).await { - Ok(Some(result)) => result, - Ok(None) => { - return Err(internal_error("failed to load app lists")); - } - Err(_) => { - let timeout_seconds = APP_LIST_LOAD_TIMEOUT.as_secs(); - return Err(internal_error(format!( - "timed out waiting for app lists after {timeout_seconds} seconds" - ))); - } - }; - - match result { - AppListLoadResult::Accessible(Ok(connectors)) => { - accessible_connectors = Some(connectors); - accessible_loaded = true; - } - AppListLoadResult::Accessible(Err(err)) => { - return Err(internal_error(err)); - } - AppListLoadResult::Directory(Ok(connectors)) => { - all_connectors = Some(connectors); - all_loaded = true; - } - AppListLoadResult::Directory(Err(err)) => { - return Err(internal_error(err)); - } - } - - let showing_interim_force_refetch = force_refetch && !(accessible_loaded && all_loaded); - let all_connectors_for_update = - if showing_interim_force_refetch && cached_all_connectors.is_some() { - cached_all_connectors.as_deref() - } else { - all_connectors.as_deref() - }; - let accessible_connectors_for_update = - if showing_interim_force_refetch && !accessible_loaded { - None - } else { - accessible_connectors.as_deref() - }; - let merged = connectors::with_app_enabled_state( - apps_list_helpers::merge_loaded_apps( - all_connectors_for_update, - accessible_connectors_for_update, - ), - &config, - ); - if apps_list_helpers::should_send_app_list_updated_notification( - merged.as_slice(), - accessible_loaded, - all_loaded, - ) && last_notified_apps.as_ref() != Some(&merged) - { - apps_list_helpers::send_app_list_updated_notification(outgoing, merged.clone()) - .await; - last_notified_apps = Some(merged.clone()); - } - - if accessible_loaded && all_loaded { - return apps_list_helpers::paginate_apps(merged.as_slice(), start, limit); - } - } - } - - async fn skills_list(&self, request_id: ConnectionRequestId, params: SkillsListParams) { - let result = self.skills_list_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn skills_list_response( - &self, - params: SkillsListParams, - ) -> Result { - let SkillsListParams { - cwds, - force_reload, - per_cwd_extra_user_roots, - } = params; - let cwds = if cwds.is_empty() { - vec![self.config.cwd.to_path_buf()] - } else { - cwds - }; - let cwd_set: HashSet = cwds.iter().cloned().collect(); - - let mut extra_roots_by_cwd: HashMap> = HashMap::new(); - for entry in per_cwd_extra_user_roots.unwrap_or_default() { - if !cwd_set.contains(&entry.cwd) { - warn!( - cwd = %entry.cwd.display(), - "ignoring per-cwd extra roots for cwd not present in skills/list cwds" - ); - continue; - } - - let mut valid_extra_roots = Vec::new(); - for root in entry.extra_user_roots { - let root = - AbsolutePathBuf::from_absolute_path_checked(root.as_path()).map_err(|_| { - invalid_request(format!( - "skills/list perCwdExtraUserRoots extraUserRoots paths must be absolute: {}", - root.display() - )) - })?; - valid_extra_roots.push(root); - } - extra_roots_by_cwd - .entry(entry.cwd) - .or_default() - .extend(valid_extra_roots); - } - - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; - let auth = self.auth_manager.auth().await; - let workspace_codex_plugins_enabled = self - .workspace_codex_plugins_enabled(&config, auth.as_ref()) - .await; - let skills_manager = self.thread_manager.skills_manager(); - let plugins_manager = self.thread_manager.plugins_manager(); - let fs = self - .thread_manager - .environment_manager() - .default_environment() - .map(|environment| environment.get_filesystem()); - let mut data = Vec::new(); - for cwd in cwds { - let (cwd_abs, config_layer_stack) = match self.resolve_cwd_config(&cwd).await { - Ok(resolved) => resolved, - Err(message) => { - let error_path = cwd.clone(); - data.push(codex_app_server_protocol::SkillsListEntry { - cwd, - skills: Vec::new(), - errors: vec![codex_app_server_protocol::SkillErrorInfo { - path: error_path, - message, - }], - }); - continue; - } - }; - let extra_roots = extra_roots_by_cwd - .get(&cwd) - .map_or(&[][..], std::vec::Vec::as_slice); - let effective_skill_roots = if workspace_codex_plugins_enabled { - let plugins_input = config.plugins_config_input(); - plugins_manager - .effective_skill_roots_for_layer_stack(&config_layer_stack, &plugins_input) - .await - } else { - Vec::new() - }; - let skills_input = codex_core::skills::SkillsLoadInput::new( - cwd_abs.clone(), - effective_skill_roots, - config_layer_stack, - config.bundled_skills_enabled(), - ); - let outcome = skills_manager - .skills_for_cwd_with_extra_user_roots( - &skills_input, - force_reload, - extra_roots, - fs.clone(), - ) - .await; - let errors = errors_to_info(&outcome.errors); - let skills = skills_to_info(&outcome.skills, &outcome.disabled_paths); - data.push(codex_app_server_protocol::SkillsListEntry { - cwd, - skills, - errors, - }); - } - Ok(SkillsListResponse { data }) - } - - async fn hooks_list(&self, request_id: ConnectionRequestId, params: HooksListParams) { - let result = self.hooks_list_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - /// Handle `hooks/list` by resolving hooks for each requested cwd. - async fn hooks_list_response( - &self, - params: HooksListParams, - ) -> Result { - let HooksListParams { cwds } = params; - let cwds = if cwds.is_empty() { - vec![self.config.cwd.to_path_buf()] - } else { - cwds - }; - - let auth = self.auth_manager.auth().await; - let plugins_manager = self.thread_manager.plugins_manager(); - let mut data = Vec::new(); - for cwd in cwds { - let config = match self - .config_manager - .load_for_cwd( - /*request_overrides*/ None, - ConfigOverrides::default(), - Some(cwd.clone()), - ) - .await - { - Ok(config) => config, - Err(err) => { - let error_path = cwd.clone(); - data.push(codex_app_server_protocol::HooksListEntry { - cwd, - hooks: Vec::new(), - warnings: Vec::new(), - errors: vec![codex_app_server_protocol::HookErrorInfo { - path: error_path, - message: err.to_string(), - }], - }); - continue; - } - }; - let workspace_codex_plugins_enabled = self - .workspace_codex_plugins_enabled(&config, auth.as_ref()) - .await; - let plugins_enabled = - config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled; - let plugin_outcome = if plugins_enabled && config.features.enabled(Feature::PluginHooks) - { - let plugins_input = config.plugins_config_input(); - plugins_manager - .plugins_for_layer_stack( - &config.config_layer_stack, - &plugins_input, - /*plugin_hooks_feature_enabled*/ true, - ) - .await - } else { - PluginLoadOutcome::default() - }; - let hooks = codex_hooks::list_hooks(codex_hooks::HooksConfig { - feature_enabled: config.features.enabled(Feature::CodexHooks), - config_layer_stack: Some(config.config_layer_stack), - plugin_hook_sources: plugin_outcome.effective_plugin_hook_sources(), - plugin_hook_load_warnings: plugin_outcome.effective_plugin_hook_warnings(), - ..Default::default() - }); - data.push(codex_app_server_protocol::HooksListEntry { - cwd, - hooks: hooks_to_info(&hooks.hooks), - warnings: hooks.warnings, - errors: Vec::new(), - }); - } - Ok(HooksListResponse { data }) - } - - async fn marketplace_remove( - &self, - request_id: ConnectionRequestId, - params: MarketplaceRemoveParams, - ) { - let result = remove_marketplace( - self.config.codex_home.to_path_buf(), - CoreMarketplaceRemoveRequest { - marketplace_name: params.marketplace_name, - }, - ) - .await - .map(|outcome| MarketplaceRemoveResponse { - marketplace_name: outcome.marketplace_name, - installed_root: outcome.removed_installed_root, - }) - .map_err(|err| match err { - MarketplaceRemoveError::InvalidRequest(message) => invalid_request(message), - MarketplaceRemoveError::Internal(message) => internal_error(message), - }); - self.outgoing.send_result(request_id, result).await; - } - - async fn marketplace_upgrade( - &self, - request_id: ConnectionRequestId, - params: MarketplaceUpgradeParams, - ) { - let result = self.marketplace_upgrade_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn marketplace_upgrade_response( - &self, - params: MarketplaceUpgradeParams, - ) -> Result { - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; - let plugins_manager = self.thread_manager.plugins_manager(); - let MarketplaceUpgradeParams { marketplace_name } = params; - let plugins_input = config.plugins_config_input(); - - let outcome = tokio::task::spawn_blocking(move || { - plugins_manager.upgrade_configured_marketplaces_for_config( - &plugins_input, - marketplace_name.as_deref(), - ) - }) - .await - .map_err(|err| internal_error(format!("failed to upgrade marketplaces: {err}")))? - .map_err(invalid_request)?; - - Ok(MarketplaceUpgradeResponse { - selected_marketplaces: outcome.selected_marketplaces, - upgraded_roots: outcome.upgraded_roots, - errors: outcome - .errors - .into_iter() - .map(|err| MarketplaceUpgradeErrorInfo { - marketplace_name: err.marketplace_name, - message: err.message, - }) - .collect(), - }) - } - - async fn marketplace_add(&self, request_id: ConnectionRequestId, params: MarketplaceAddParams) { - let result = add_marketplace_to_codex_home( - self.config.codex_home.to_path_buf(), - MarketplaceAddRequest { - source: params.source, - ref_name: params.ref_name, - sparse_paths: params.sparse_paths.unwrap_or_default(), - }, - ) - .await - .map(|outcome| MarketplaceAddResponse { - marketplace_name: outcome.marketplace_name, - installed_root: outcome.installed_root, - already_added: outcome.already_added, - }) - .map_err(|err| match err { - MarketplaceAddError::InvalidRequest(message) => invalid_request(message), - MarketplaceAddError::Internal(message) => internal_error(message), - }); - self.outgoing.send_result(request_id, result).await; - } - - async fn skills_config_write( - &self, - request_id: ConnectionRequestId, - params: SkillsConfigWriteParams, - ) { - let result = self.skills_config_write_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn skills_config_write_response( - &self, - params: SkillsConfigWriteParams, - ) -> Result { - let SkillsConfigWriteParams { - path, - name, - enabled, - } = params; - let edit = match (path, name) { - (Some(path), None) => ConfigEdit::SetSkillConfig { - path: path.into_path_buf(), - enabled, - }, - (None, Some(name)) if !name.trim().is_empty() => { - ConfigEdit::SetSkillConfigByName { name, enabled } - } - _ => { - return Err(invalid_params( - "skills/config/write requires exactly one of path or name", - )); - } - }; - let edits = vec![edit]; - ConfigEditsBuilder::new(&self.config.codex_home) - .with_edits(edits) - .apply() - .await - .map(|()| { - self.thread_manager.plugins_manager().clear_cache(); - self.thread_manager.skills_manager().clear_cache(); - SkillsConfigWriteResponse { - effective_enabled: enabled, - } - }) - .map_err(|err| internal_error(format!("failed to update skill settings: {err}"))) - } - - async fn turn_start( - &self, - request_id: ConnectionRequestId, - params: TurnStartParams, - app_server_client_name: Option, - app_server_client_version: Option, - ) { - let result = async { - if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { - self.track_error_response( - &request_id, - &error, - Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), - ); - return Err(error); - } - let (thread_id, thread) = - self.load_thread(¶ms.thread_id) - .await - .inspect_err(|error| { - self.track_error_response(&request_id, error, /*error_type*/ None); - })?; - Self::set_app_server_client_info( - thread.as_ref(), - app_server_client_name, - app_server_client_version, - ) - .await - .inspect_err(|error| { - self.track_error_response(&request_id, error, /*error_type*/ None); - })?; - - let collaboration_mode = params - .collaboration_mode - .map(|mode| self.normalize_turn_start_collaboration_mode(mode)); - let environment_selections = self.parse_environment_selections(params.environments)?; - - // Map v2 input items to core input items. - let mapped_items: Vec = params - .input - .into_iter() - .map(V2UserInput::into_core) - .collect(); - let turn_has_input = !mapped_items.is_empty(); - - let has_any_overrides = params.cwd.is_some() - || params.approval_policy.is_some() - || params.approvals_reviewer.is_some() - || params.sandbox_policy.is_some() - || params.permissions.is_some() - || params.model.is_some() - || params.service_tier.is_some() - || params.effort.is_some() - || params.summary.is_some() - || collaboration_mode.is_some() - || params.personality.is_some(); - - if params.sandbox_policy.is_some() && params.permissions.is_some() { - return Err(invalid_request( - "`permissions` cannot be combined with `sandboxPolicy`", - )); - } - - let cwd = params.cwd; - let approval_policy = params.approval_policy.map(AskForApproval::to_core); - let approvals_reviewer = params - .approvals_reviewer - .map(codex_app_server_protocol::ApprovalsReviewer::to_core); - let sandbox_policy = params.sandbox_policy.map(|p| p.to_core()); - let (permission_profile, active_permission_profile) = - if let Some(permissions) = params.permissions { - let snapshot = thread.config_snapshot().await; - let mut overrides = ConfigOverrides { - cwd: cwd.clone(), - codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), - main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), - ..Default::default() - }; - apply_permission_profile_selection_to_config_overrides( - &mut overrides, - Some(permissions), - ); - let config = self - .config_manager - .load_for_cwd( - /*request_overrides*/ None, - overrides, - Some(snapshot.cwd.to_path_buf()), - ) - .await - .map_err(|err| config_load_error(&err))?; - // Startup config is allowed to fall back when requirements - // disallow a configured profile. An explicit turn request - // is different: reject it before accepting user input. - if let Some(warning) = config.startup_warnings.iter().find(|warning| { - warning.contains("Configured value for `permission_profile` is disallowed") - }) { - return Err(invalid_request(format!( - "invalid turn context override: {warning}" - ))); - } - ( - Some(config.permissions.permission_profile()), - config.permissions.active_permission_profile(), - ) - } else { - (None, None) - }; - let model = params.model; - let effort = params.effort.map(Some); - let summary = params.summary; - let service_tier = params.service_tier; - let personality = params.personality; - - // If any overrides are provided, validate them synchronously so the - // request can fail before accepting user input. The actual update is - // still queued together with the input below to preserve submission order. - if has_any_overrides { - thread - .validate_turn_context_overrides(CodexThreadTurnContextOverrides { - cwd: cwd.clone(), - approval_policy, - approvals_reviewer, - sandbox_policy: sandbox_policy.clone(), - permission_profile: permission_profile.clone(), - active_permission_profile: active_permission_profile.clone(), - windows_sandbox_level: None, - model: model.clone(), - effort, - summary, - service_tier, - collaboration_mode: collaboration_mode.clone(), - personality, - }) - .await - .map_err(|err| { - invalid_request(format!("invalid turn context override: {err}")) - })?; - } - - // Start the turn by submitting the user input. Return its submission id as turn_id. - let turn_op = if has_any_overrides { - Op::UserInputWithTurnContext { - items: mapped_items, - environments: environment_selections, - final_output_json_schema: params.output_schema, - responsesapi_client_metadata: params.responsesapi_client_metadata, - cwd, - approval_policy, - approvals_reviewer, - sandbox_policy, - permission_profile, - active_permission_profile, - windows_sandbox_level: None, - model, - effort, - summary, - service_tier, - collaboration_mode, - personality, - } - } else { - Op::UserInput { - items: mapped_items, - environments: environment_selections, - final_output_json_schema: params.output_schema, - responsesapi_client_metadata: params.responsesapi_client_metadata, - } - }; - let turn_id = self - .submit_core_op(&request_id, thread.as_ref(), turn_op) - .await - .map_err(|err| { - let error = internal_error(format!("failed to start turn: {err}")); - self.track_error_response(&request_id, &error, /*error_type*/ None); - error - })?; - - if turn_has_input { - let config_snapshot = thread.config_snapshot().await; - codex_memories_write::start_memories_startup_task( - Arc::clone(&self.thread_manager), - Arc::clone(&self.auth_manager), - thread_id, - Arc::clone(&thread), - thread.config().await, - &config_snapshot.session_source, - ); - } - - self.outgoing - .record_request_turn_id(&request_id, &turn_id) - .await; - let turn = Turn { - id: turn_id, - items: vec![], - error: None, - status: TurnStatus::InProgress, - started_at: None, - completed_at: None, - duration_ms: None, - }; - - Ok::<_, JSONRPCErrorError>(TurnStartResponse { turn }) - } - .await; - - match result { - Ok(response) => { - self.outgoing.send_response(request_id, response).await; - } - Err(error) => { - self.outgoing.send_error(request_id, error).await; - } - } - } - - async fn thread_inject_items( - &self, - request_id: ConnectionRequestId, - params: ThreadInjectItemsParams, - ) { - let result = self.thread_inject_items_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn thread_inject_items_response( - &self, - params: ThreadInjectItemsParams, - ) -> Result { - let (_, thread) = self.load_thread(¶ms.thread_id).await?; - - let items = params - .items - .into_iter() - .enumerate() - .map(|(index, value)| { - serde_json::from_value::(value) - .map_err(|err| format!("items[{index}] is not a valid response item: {err}")) - }) - .collect::, _>>() - .map_err(invalid_request)?; - - thread - .inject_response_items(items) - .await - .map_err(|err| match err { - CodexErr::InvalidRequest(message) => invalid_request(message), - err => internal_error(format!("failed to inject response items: {err}")), - })?; - Ok(ThreadInjectItemsResponse {}) - } - - async fn set_app_server_client_info( - thread: &CodexThread, - app_server_client_name: Option, - app_server_client_version: Option, - ) -> Result<(), JSONRPCErrorError> { - thread - .set_app_server_client_info(app_server_client_name, app_server_client_version) - .await - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to set app server client info: {err}"), - data: None, - }) - } - - async fn turn_steer(&self, request_id: ConnectionRequestId, params: TurnSteerParams) { - let result = async { - let (_, thread) = self - .load_thread(¶ms.thread_id) - .await - .inspect_err(|error| { - self.track_error_response(&request_id, error, /*error_type*/ None); - })?; - - if params.expected_turn_id.is_empty() { - return Err(invalid_request("expectedTurnId must not be empty")); - } - self.outgoing - .record_request_turn_id(&request_id, ¶ms.expected_turn_id) - .await; - if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { - self.track_error_response( - &request_id, - &error, - Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), - ); - return Err(error); - } - - let mapped_items: Vec = params - .input - .into_iter() - .map(V2UserInput::into_core) - .collect(); - - let turn_id = thread - .steer_input( - mapped_items, - Some(¶ms.expected_turn_id), - params.responsesapi_client_metadata, - ) - .await - .map_err(|err| { - let (code, message, data, error_type) = match err { - SteerInputError::NoActiveTurn(_) => ( - INVALID_REQUEST_ERROR_CODE, - "no active turn to steer".to_string(), - None, - Some(AnalyticsJsonRpcError::TurnSteer( - TurnSteerRequestError::NoActiveTurn, - )), - ), - SteerInputError::ExpectedTurnMismatch { expected, actual } => ( - INVALID_REQUEST_ERROR_CODE, - format!("expected active turn id `{expected}` but found `{actual}`"), - None, - Some(AnalyticsJsonRpcError::TurnSteer( - TurnSteerRequestError::ExpectedTurnMismatch, - )), - ), - SteerInputError::ActiveTurnNotSteerable { turn_kind } => { - let (message, turn_steer_error) = match turn_kind { - codex_protocol::protocol::NonSteerableTurnKind::Review => ( - "cannot steer a review turn".to_string(), - TurnSteerRequestError::NonSteerableReview, - ), - codex_protocol::protocol::NonSteerableTurnKind::Compact => ( - "cannot steer a compact turn".to_string(), - TurnSteerRequestError::NonSteerableCompact, - ), - }; - let error = TurnError { - message: message.clone(), - codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { - turn_kind: turn_kind.into(), - }), - additional_details: None, - }; - let data = match serde_json::to_value(error) { - Ok(data) => Some(data), - Err(error) => { - tracing::error!( - ?error, - "failed to serialize active-turn-not-steerable turn error" - ); - None - } - }; - ( - INVALID_REQUEST_ERROR_CODE, - message, - data, - Some(AnalyticsJsonRpcError::TurnSteer(turn_steer_error)), - ) - } - SteerInputError::EmptyInput => ( - INVALID_REQUEST_ERROR_CODE, - "input must not be empty".to_string(), - None, - Some(AnalyticsJsonRpcError::Input(InputError::Empty)), - ), - }; - let error = JSONRPCErrorError { - code, - message, - data, - }; - self.track_error_response(&request_id, &error, error_type); - error - })?; - Ok::<_, JSONRPCErrorError>(TurnSteerResponse { turn_id }) - } - .await; - - match result { - Ok(response) => { - self.outgoing.send_response(request_id, response).await; - } - Err(error) => { - self.outgoing.send_error(request_id, error).await; - } - } - } - - async fn prepare_realtime_conversation_thread( - &self, - request_id: &ConnectionRequestId, - thread_id: &str, - ) -> Result)>, JSONRPCErrorError> { - let (thread_id, thread) = self.load_thread(thread_id).await?; - - match self - .ensure_conversation_listener( - thread_id, - request_id.connection_id, - /*raw_events_enabled*/ false, - ) - .await - { - Ok(EnsureConversationListenerResult::Attached) => {} - Ok(EnsureConversationListenerResult::ConnectionClosed) => { - return Ok(None); - } - Err(error) => return Err(error), - } - - if !thread.enabled(Feature::RealtimeConversation) { - return Err(invalid_request(format!( - "thread {thread_id} does not support realtime conversation" - ))); - } - - Ok(Some((thread_id, thread))) - } - - async fn thread_realtime_start( - &self, - request_id: ConnectionRequestId, - params: ThreadRealtimeStartParams, - ) { - let result = async { - let Some((_, thread)) = self - .prepare_realtime_conversation_thread(&request_id, ¶ms.thread_id) - .await? - else { - return Ok(None); - }; - self.submit_core_op( - &request_id, - thread.as_ref(), - Op::RealtimeConversationStart(ConversationStartParams { - output_modality: params.output_modality, - prompt: params.prompt, - realtime_session_id: params.realtime_session_id, - transport: params.transport.map(|transport| match transport { - ThreadRealtimeStartTransport::Websocket => { - ConversationStartTransport::Websocket - } - ThreadRealtimeStartTransport::Webrtc { sdp } => { - ConversationStartTransport::Webrtc { sdp } - } - }), - voice: params.voice, - }), - ) - .await - .map_err(|err| { - internal_error(format!("failed to start realtime conversation: {err}")) - })?; - Ok::<_, JSONRPCErrorError>(Some(ThreadRealtimeStartResponse::default())) - } - .await; - self.send_optional_result(request_id, result).await; - } - - async fn thread_realtime_append_audio( - &self, - request_id: ConnectionRequestId, - params: ThreadRealtimeAppendAudioParams, - ) { - let result = async { - let Some((_, thread)) = self - .prepare_realtime_conversation_thread(&request_id, ¶ms.thread_id) - .await? - else { - return Ok(None); - }; - self.submit_core_op( - &request_id, - thread.as_ref(), - Op::RealtimeConversationAudio(ConversationAudioParams { - frame: params.audio.into(), - }), - ) - .await - .map_err(|err| { - internal_error(format!( - "failed to append realtime conversation audio: {err}" - )) - })?; - Ok::<_, JSONRPCErrorError>(Some(ThreadRealtimeAppendAudioResponse::default())) - } - .await; - self.send_optional_result(request_id, result).await; - } - - async fn thread_realtime_append_text( - &self, - request_id: ConnectionRequestId, - params: ThreadRealtimeAppendTextParams, - ) { - let result = async { - let Some((_, thread)) = self - .prepare_realtime_conversation_thread(&request_id, ¶ms.thread_id) - .await? - else { - return Ok(None); - }; - self.submit_core_op( - &request_id, - thread.as_ref(), - Op::RealtimeConversationText(ConversationTextParams { text: params.text }), - ) - .await - .map_err(|err| { - internal_error(format!( - "failed to append realtime conversation text: {err}" - )) - })?; - Ok::<_, JSONRPCErrorError>(Some(ThreadRealtimeAppendTextResponse::default())) - } - .await; - self.send_optional_result(request_id, result).await; - } - - async fn thread_realtime_stop( - &self, - request_id: ConnectionRequestId, - params: ThreadRealtimeStopParams, - ) { - let result = async { - let Some((_, thread)) = self - .prepare_realtime_conversation_thread(&request_id, ¶ms.thread_id) - .await? - else { - return Ok(None); - }; - self.submit_core_op(&request_id, thread.as_ref(), Op::RealtimeConversationClose) - .await - .map_err(|err| { - internal_error(format!("failed to stop realtime conversation: {err}")) - })?; - Ok::<_, JSONRPCErrorError>(Some(ThreadRealtimeStopResponse::default())) - } - .await; - self.send_optional_result(request_id, result).await; - } - - async fn thread_realtime_list_voices( - &self, - request_id: ConnectionRequestId, - _params: ThreadRealtimeListVoicesParams, - ) { - self.outgoing - .send_response( - request_id, - ThreadRealtimeListVoicesResponse { - voices: RealtimeVoicesList::builtin(), - }, - ) - .await; - } - - fn build_review_turn(turn_id: String, display_text: &str) -> Turn { - let items = if display_text.is_empty() { - Vec::new() - } else { - vec![ThreadItem::UserMessage { - id: turn_id.clone(), - content: vec![V2UserInput::Text { - text: display_text.to_string(), - // Review prompt display text is synthesized; no UI element ranges to preserve. - text_elements: Vec::new(), - }], - }] - }; - - Turn { - id: turn_id, - items, - error: None, - status: TurnStatus::InProgress, - started_at: None, - completed_at: None, - duration_ms: None, - } - } - - async fn emit_review_started( - &self, - request_id: &ConnectionRequestId, - turn: Turn, - review_thread_id: String, - ) { - let response = ReviewStartResponse { - turn, - review_thread_id, - }; - self.outgoing - .send_response(request_id.clone(), response) - .await; - } - - async fn start_inline_review( - &self, - request_id: &ConnectionRequestId, - parent_thread: Arc, - review_request: ReviewRequest, - display_text: &str, - parent_thread_id: String, - ) -> std::result::Result<(), JSONRPCErrorError> { - let turn_id = self - .submit_core_op( - request_id, - parent_thread.as_ref(), - Op::Review { review_request }, - ) - .await; - - match turn_id { - Ok(turn_id) => { - let turn = Self::build_review_turn(turn_id, display_text); - self.emit_review_started(request_id, turn, parent_thread_id) - .await; - Ok(()) - } - Err(err) => Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to start review: {err}"), - data: None, - }), - } - } - - async fn start_detached_review( - &self, - request_id: &ConnectionRequestId, - parent_thread_id: ThreadId, - parent_thread: Arc, - review_request: ReviewRequest, - display_text: &str, - ) -> std::result::Result<(), JSONRPCErrorError> { - let rollout_path = if let Some(path) = parent_thread.rollout_path() { - path - } else { - find_thread_path_by_id_str(&self.config.codex_home, &parent_thread_id.to_string()) - .await - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to locate thread id {parent_thread_id}: {err}"), - data: None, - })? - .ok_or_else(|| JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("no rollout found for thread id {parent_thread_id}"), - data: None, - })? - }; - - let mut config = self.config.as_ref().clone(); - if let Some(review_model) = &config.review_model { - config.model = Some(review_model.clone()); - } - - let NewThread { - thread_id, - thread: review_thread, - session_configured, - .. - } = self - .thread_manager - .fork_thread( - ForkSnapshot::Interrupted, - config.clone(), - rollout_path, - /*persist_extended_history*/ false, - self.request_trace_context(request_id).await, - ) - .await - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("error creating detached review thread: {err}"), - data: None, - })?; - - Self::log_listener_attach_result( - self.ensure_conversation_listener( - thread_id, - request_id.connection_id, - /*raw_events_enabled*/ false, - ) - .await, - thread_id, - request_id.connection_id, - "review thread", - ); - - let fallback_provider = self.config.model_provider_id.as_str(); - if let Some(rollout_path) = review_thread.rollout_path() { - match read_summary_from_rollout(rollout_path.as_path(), fallback_provider).await { - Ok(summary) => { - let mut thread = summary_to_thread(summary, &self.config.cwd); - self.thread_watch_manager - .upsert_thread_silently(thread.clone()) - .await; - thread.status = resolve_thread_status( - self.thread_watch_manager - .loaded_status_for_thread(&thread.id) - .await, - /*has_in_progress_turn*/ false, - ); - let notif = thread_started_notification(thread); - self.outgoing - .send_server_notification(ServerNotification::ThreadStarted(notif)) - .await; - } - Err(err) => { - tracing::warn!( - "failed to load summary for review thread {}: {}", - session_configured.session_id, - err - ); - } - } - } else { - tracing::warn!( - "review thread {} has no rollout path", - session_configured.session_id - ); - } - - let turn_id = self - .submit_core_op( - request_id, - review_thread.as_ref(), - Op::Review { review_request }, - ) - .await - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to start detached review turn: {err}"), - data: None, - })?; - - let turn = Self::build_review_turn(turn_id, display_text); - let review_thread_id = thread_id.to_string(); - self.emit_review_started(request_id, turn, review_thread_id) - .await; - - Ok(()) - } - - async fn review_start(&self, request_id: ConnectionRequestId, params: ReviewStartParams) { - let ReviewStartParams { - thread_id, - target, - delivery, - } = params; - let result = async { - let (parent_thread_id, parent_thread) = self.load_thread(&thread_id).await?; - let (review_request, display_text) = Self::review_request_from_target(target)?; - match delivery.unwrap_or(ApiReviewDelivery::Inline).to_core() { - CoreReviewDelivery::Inline => { - self.start_inline_review( - &request_id, - parent_thread, - review_request, - display_text.as_str(), - thread_id, - ) - .await?; - } - CoreReviewDelivery::Detached => { - self.start_detached_review( - &request_id, - parent_thread_id, - parent_thread, - review_request, - display_text.as_str(), - ) - .await?; - } - } - Ok::<_, JSONRPCErrorError>(None::) - } - .await; - self.send_optional_result(request_id, result).await; - } - - async fn turn_interrupt(&self, request_id: ConnectionRequestId, params: TurnInterruptParams) { - let TurnInterruptParams { thread_id, turn_id } = params; - let is_startup_interrupt = turn_id.is_empty(); - - let result = async { - let (thread_uuid, thread) = self.load_thread(&thread_id).await?; - - // Record turn interrupts so we can reply when TurnAborted arrives. Startup - // interrupts do not have a turn and are acknowledged after submission. - if !is_startup_interrupt { - let thread_state = self.thread_state_manager.thread_state(thread_uuid).await; - let is_running = matches!(thread.agent_status().await, AgentStatus::Running); - { - let mut thread_state = thread_state.lock().await; - if let Some(active_turn) = thread_state.active_turn_snapshot() { - if active_turn.id != turn_id { - return Err(invalid_request(format!( - "expected active turn id {turn_id} but found {}", - active_turn.id - ))); - } - } else if thread_state.last_terminal_turn_id.as_deref() - == Some(turn_id.as_str()) - || !is_running - { - return Err(invalid_request("no active turn to interrupt")); - } - thread_state.pending_interrupts.push(request_id.clone()); - } - - self.outgoing - .record_request_turn_id(&request_id, &turn_id) - .await; - } - - // Submit the interrupt. Turn interrupts respond upon TurnAborted; startup - // interrupts respond here because startup cancellation has no turn event. - match self - .submit_core_op(&request_id, thread.as_ref(), Op::Interrupt) - .await - { - Ok(_) if is_startup_interrupt => Ok(Some(TurnInterruptResponse {})), - Ok(_) => Ok(None), - Err(err) => { - if !is_startup_interrupt { - let thread_state = - self.thread_state_manager.thread_state(thread_uuid).await; - let mut thread_state = thread_state.lock().await; - thread_state - .pending_interrupts - .retain(|pending_request_id| pending_request_id != &request_id); - } - let interrupt_target = if is_startup_interrupt { - "startup" - } else { - "turn" - }; - Err(internal_error(format!( - "failed to interrupt {interrupt_target}: {err}" - ))) - } - } - } - .await; - self.send_optional_result(request_id, result).await; - } - - async fn ensure_conversation_listener( - &self, - conversation_id: ThreadId, - connection_id: ConnectionId, - raw_events_enabled: bool, - ) -> Result { - Self::ensure_conversation_listener_task( - ListenerTaskContext { - thread_manager: Arc::clone(&self.thread_manager), - thread_state_manager: self.thread_state_manager.clone(), - outgoing: Arc::clone(&self.outgoing), - pending_thread_unloads: Arc::clone(&self.pending_thread_unloads), - analytics_events_client: self.analytics_events_client.clone(), - thread_watch_manager: self.thread_watch_manager.clone(), - thread_list_state_permit: self.thread_list_state_permit.clone(), - fallback_model_provider: self.config.model_provider_id.clone(), - codex_home: self.config.codex_home.to_path_buf(), - }, - conversation_id, - connection_id, - raw_events_enabled, - ) - .await - } - - #[expect( - clippy::await_holding_invalid_type, - reason = "listener subscription must be serialized against pending thread unloads" - )] - async fn ensure_conversation_listener_task( - listener_task_context: ListenerTaskContext, - conversation_id: ThreadId, - connection_id: ConnectionId, - raw_events_enabled: bool, - ) -> Result { - let conversation = match listener_task_context - .thread_manager - .get_thread(conversation_id) - .await - { - Ok(conv) => conv, - Err(_) => { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("thread not found: {conversation_id}"), - data: None, - }); - } - }; - let thread_state = { - let pending_thread_unloads = listener_task_context.pending_thread_unloads.lock().await; - if pending_thread_unloads.contains(&conversation_id) { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!( - "thread {conversation_id} is closing; retry after the thread is closed" - ), - data: None, - }); - } - let Some(thread_state) = listener_task_context - .thread_state_manager - .try_ensure_connection_subscribed( - conversation_id, - connection_id, - raw_events_enabled, - ) - .await - else { - return Ok(EnsureConversationListenerResult::ConnectionClosed); - }; - thread_state - }; - if let Err(error) = Self::ensure_listener_task_running_task( - listener_task_context.clone(), - conversation_id, - conversation, - thread_state, - ) - .await - { - let _ = listener_task_context - .thread_state_manager - .unsubscribe_connection_from_thread(conversation_id, connection_id) - .await; - return Err(error); - } - Ok(EnsureConversationListenerResult::Attached) - } - - fn log_listener_attach_result( - result: Result, - thread_id: ThreadId, - connection_id: ConnectionId, - thread_kind: &'static str, - ) { - match result { - Ok(EnsureConversationListenerResult::Attached) => {} - Ok(EnsureConversationListenerResult::ConnectionClosed) => { - tracing::debug!( - thread_id = %thread_id, - connection_id = ?connection_id, - "skipping auto-attach for closed connection" - ); - } - Err(err) => { - tracing::warn!( - "failed to attach listener for {thread_kind} {thread_id}: {message}", - message = err.message - ); - } - } - } - - async fn ensure_listener_task_running( - &self, - conversation_id: ThreadId, - conversation: Arc, - thread_state: Arc>, - ) -> Result<(), JSONRPCErrorError> { - Self::ensure_listener_task_running_task( - ListenerTaskContext { - thread_manager: Arc::clone(&self.thread_manager), - thread_state_manager: self.thread_state_manager.clone(), - outgoing: Arc::clone(&self.outgoing), - pending_thread_unloads: Arc::clone(&self.pending_thread_unloads), - analytics_events_client: self.analytics_events_client.clone(), - thread_watch_manager: self.thread_watch_manager.clone(), - thread_list_state_permit: self.thread_list_state_permit.clone(), - fallback_model_provider: self.config.model_provider_id.clone(), - codex_home: self.config.codex_home.to_path_buf(), - }, - conversation_id, - conversation, - thread_state, - ) - .await - } - - async fn ensure_listener_task_running_task( - listener_task_context: ListenerTaskContext, - conversation_id: ThreadId, - conversation: Arc, - thread_state: Arc>, - ) -> Result<(), JSONRPCErrorError> { - let (cancel_tx, mut cancel_rx) = oneshot::channel(); - let Some(mut unloading_state) = UnloadingState::new( - &listener_task_context, - conversation_id, - THREAD_UNLOADING_DELAY, - ) - .await - else { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!( - "thread {conversation_id} is closing; retry after the thread is closed" - ), - data: None, - }); - }; - let (mut listener_command_rx, listener_generation) = { - let mut thread_state = thread_state.lock().await; - if thread_state.listener_matches(&conversation) { - return Ok(()); - } - thread_state.set_listener(cancel_tx, &conversation) - }; - let ListenerTaskContext { - outgoing, - thread_manager, - thread_state_manager, - pending_thread_unloads, - analytics_events_client: _, - thread_watch_manager, - thread_list_state_permit, - fallback_model_provider, - codex_home, - } = listener_task_context; - let outgoing_for_task = Arc::clone(&outgoing); - tokio::spawn(async move { - loop { - tokio::select! { - biased; - _ = &mut cancel_rx => { - // Listener was superseded or the thread is being torn down. - break; - } - listener_command = listener_command_rx.recv() => { - let Some(listener_command) = listener_command else { - break; - }; - handle_thread_listener_command( - conversation_id, - &conversation, - codex_home.as_path(), - &thread_state_manager, - &thread_state, - &thread_watch_manager, - &outgoing_for_task, - &pending_thread_unloads, - listener_command, - ) - .await; - } - event = conversation.next_event() => { - let event = match event { - Ok(event) => event, - Err(err) => { - tracing::warn!("thread.next_event() failed with: {err}"); - break; - } - }; - - // Track the event before emitting any typed - // translations so thread-local state such as raw event - // opt-in stays synchronized with the conversation. - let raw_events_enabled = { - let mut thread_state = thread_state.lock().await; - thread_state.track_current_turn_event(&event.id, &event.msg); - thread_state.experimental_raw_events - }; - let subscribed_connection_ids = thread_state_manager - .subscribed_connection_ids(conversation_id) - .await; - let thread_outgoing = ThreadScopedOutgoingMessageSender::new( - outgoing_for_task.clone(), - subscribed_connection_ids, - conversation_id, - ); - - if let EventMsg::RawResponseItem(raw_response_item_event) = &event.msg - && !raw_events_enabled - { - maybe_emit_hook_prompt_item_completed( - conversation_id, - &event.id, - &raw_response_item_event.item, - &thread_outgoing, - ) - .await; - continue; - } - - apply_bespoke_event_handling( - event.clone(), - conversation_id, - conversation.clone(), - thread_manager.clone(), - Some(listener_task_context.analytics_events_client.clone()), - thread_outgoing, - thread_state.clone(), - thread_watch_manager.clone(), - thread_list_state_permit.clone(), - fallback_model_provider.clone(), - codex_home.as_path(), - ) - .await; - } - unloading_watchers_open = unloading_state.wait_for_unloading_trigger() => { - if !unloading_watchers_open { - break; - } - if !unloading_state.should_unload_now() { - continue; - } - if matches!(conversation.agent_status().await, AgentStatus::Running) { - unloading_state.note_thread_activity_observed(); - continue; - } - { - let mut pending_thread_unloads = pending_thread_unloads.lock().await; - if pending_thread_unloads.contains(&conversation_id) { - continue; - } - if !unloading_state.should_unload_now() { - continue; - } - pending_thread_unloads.insert(conversation_id); - } - Self::unload_thread_without_subscribers( - thread_manager.clone(), - outgoing_for_task.clone(), - pending_thread_unloads.clone(), - thread_state_manager.clone(), - thread_watch_manager.clone(), - conversation_id, - conversation.clone(), - ) - .await; - break; - } - } - } - - let mut thread_state = thread_state.lock().await; - if thread_state.listener_generation == listener_generation { - thread_state.clear_listener(); - } - }); - Ok(()) - } - async fn git_diff_to_origin(&self, request_id: ConnectionRequestId, cwd: PathBuf) { - let result = git_diff_to_remote(&cwd) - .await - .map(|value| GitDiffToRemoteResponse { - sha: value.sha, - diff: value.diff, - }) - .ok_or_else(|| { - invalid_request(format!( - "failed to compute git diff to remote for cwd: {cwd:?}" - )) - }); - self.outgoing.send_result(request_id, result).await; - } - - async fn fuzzy_file_search( - &self, - request_id: ConnectionRequestId, - params: FuzzyFileSearchParams, - ) { - let FuzzyFileSearchParams { - query, - roots, - cancellation_token, - } = params; - - let cancel_flag = match cancellation_token.clone() { - Some(token) => { - let mut pending_fuzzy_searches = self.pending_fuzzy_searches.lock().await; - // if a cancellation_token is provided and a pending_request exists for - // that token, cancel it - if let Some(existing) = pending_fuzzy_searches.get(&token) { - existing.store(true, Ordering::Relaxed); - } - let flag = Arc::new(AtomicBool::new(false)); - pending_fuzzy_searches.insert(token.clone(), flag.clone()); - flag - } - None => Arc::new(AtomicBool::new(false)), - }; - - let results = match query.as_str() { - "" => vec![], - _ => run_fuzzy_file_search(query, roots, cancel_flag.clone()).await, - }; - - if let Some(token) = cancellation_token { - let mut pending_fuzzy_searches = self.pending_fuzzy_searches.lock().await; - if let Some(current_flag) = pending_fuzzy_searches.get(&token) - && Arc::ptr_eq(current_flag, &cancel_flag) - { - pending_fuzzy_searches.remove(&token); - } - } - - let response = FuzzyFileSearchResponse { files: results }; - self.outgoing.send_response(request_id, response).await; - } - - async fn fuzzy_file_search_session_start( - &self, - request_id: ConnectionRequestId, - params: FuzzyFileSearchSessionStartParams, - ) { - let result = self.fuzzy_file_search_session_start_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn fuzzy_file_search_session_start_response( - &self, - params: FuzzyFileSearchSessionStartParams, - ) -> Result { - let FuzzyFileSearchSessionStartParams { session_id, roots } = params; - if session_id.is_empty() { - return Err(invalid_request("sessionId must not be empty")); - } - - let session = - start_fuzzy_file_search_session(session_id.clone(), roots, self.outgoing.clone()) - .map_err(|err| { - internal_error(format!("failed to start fuzzy file search session: {err}")) - })?; - self.fuzzy_search_sessions - .lock() - .await - .insert(session_id, session); - Ok(FuzzyFileSearchSessionStartResponse {}) - } - - async fn fuzzy_file_search_session_update( - &self, - request_id: ConnectionRequestId, - params: FuzzyFileSearchSessionUpdateParams, - ) { - let result = self.fuzzy_file_search_session_update_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn fuzzy_file_search_session_update_response( - &self, - params: FuzzyFileSearchSessionUpdateParams, - ) -> Result { - let FuzzyFileSearchSessionUpdateParams { session_id, query } = params; - let found = { - let sessions = self.fuzzy_search_sessions.lock().await; - if let Some(session) = sessions.get(&session_id) { - session.update_query(query); - true - } else { - false - } - }; - if !found { - return Err(invalid_request(format!( - "fuzzy file search session not found: {session_id}" - ))); - } - - Ok(FuzzyFileSearchSessionUpdateResponse {}) - } - - async fn fuzzy_file_search_session_stop( - &self, - request_id: ConnectionRequestId, - params: FuzzyFileSearchSessionStopParams, - ) { - let FuzzyFileSearchSessionStopParams { session_id } = params; - { - let mut sessions = self.fuzzy_search_sessions.lock().await; - sessions.remove(&session_id); - } - - self.outgoing - .send_response(request_id, FuzzyFileSearchSessionStopResponse {}) - .await; - } - - async fn upload_feedback(&self, request_id: ConnectionRequestId, params: FeedbackUploadParams) { - let result = self.upload_feedback_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn upload_feedback_response( - &self, - params: FeedbackUploadParams, - ) -> Result { - if !self.config.feedback_enabled { - return Err(invalid_request( - "sending feedback is disabled by configuration", - )); - } - - let FeedbackUploadParams { - classification, - reason, - thread_id, - include_logs, - extra_log_files, - tags, - } = params; - - let conversation_id = match thread_id.as_deref() { - Some(thread_id) => match ThreadId::from_string(thread_id) { - Ok(conversation_id) => Some(conversation_id), - Err(err) => return Err(invalid_request(format!("invalid thread id: {err}"))), - }, - None => None, - }; - - if let Some(chatgpt_user_id) = self - .auth_manager - .auth_cached() - .and_then(|auth| auth.get_chatgpt_user_id()) - { - tracing::info!(target: "feedback_tags", chatgpt_user_id); - } - let snapshot = self.feedback.snapshot(conversation_id); - let thread_id = snapshot.thread_id.clone(); - let (feedback_thread_ids, sqlite_feedback_logs, state_db_ctx) = if include_logs { - if let Some(log_db) = self.log_db.as_ref() { - log_db.flush().await; - } - let state_db_ctx = get_state_db(&self.config).await; - let feedback_thread_ids = match conversation_id { - Some(conversation_id) => match self - .thread_manager - .list_agent_subtree_thread_ids(conversation_id) - .await - { - Ok(thread_ids) => thread_ids, - Err(err) => { - warn!( - "failed to list feedback subtree for thread_id={conversation_id}: {err}" - ); - let mut thread_ids = vec![conversation_id]; - if let Some(state_db_ctx) = state_db_ctx.as_ref() { - for status in [ - codex_state::DirectionalThreadSpawnEdgeStatus::Open, - codex_state::DirectionalThreadSpawnEdgeStatus::Closed, - ] { - match state_db_ctx - .list_thread_spawn_descendants_with_status( - conversation_id, - status, - ) - .await - { - Ok(descendant_ids) => thread_ids.extend(descendant_ids), - Err(err) => warn!( - "failed to list persisted feedback subtree for thread_id={conversation_id}: {err}" - ), - } - } - } - thread_ids - } - }, - None => Vec::new(), - }; - let sqlite_feedback_logs = if let Some(state_db_ctx) = state_db_ctx.as_ref() - && !feedback_thread_ids.is_empty() - { - let thread_id_texts = feedback_thread_ids - .iter() - .map(ToString::to_string) - .collect::>(); - let thread_id_refs = thread_id_texts - .iter() - .map(String::as_str) - .collect::>(); - match state_db_ctx - .query_feedback_logs_for_threads(&thread_id_refs) - .await - { - Ok(logs) if logs.is_empty() => None, - Ok(logs) => Some(logs), - Err(err) => { - let thread_ids = thread_id_texts.join(", "); - warn!( - "failed to query feedback logs from sqlite for thread_ids=[{thread_ids}]: {err}" - ); - None - } - } - } else { - None - }; - (feedback_thread_ids, sqlite_feedback_logs, state_db_ctx) - } else { - (Vec::new(), None, None) - }; - - let mut attachment_paths = Vec::new(); - let mut seen_attachment_paths = HashSet::new(); - if include_logs { - for feedback_thread_id in &feedback_thread_ids { - let Some(rollout_path) = self - .resolve_rollout_path(*feedback_thread_id, state_db_ctx.as_ref()) - .await - else { - continue; - }; - if seen_attachment_paths.insert(rollout_path.clone()) { - attachment_paths.push(FeedbackAttachmentPath { - path: rollout_path, - attachment_filename_override: None, - }); - } - } - if let Some(conversation_id) = conversation_id - && let Ok(conversation) = self.thread_manager.get_thread(conversation_id).await - && let Some(guardian_rollout_path) = - conversation.guardian_trunk_rollout_path().await - && seen_attachment_paths.insert(guardian_rollout_path.clone()) - { - attachment_paths.push(FeedbackAttachmentPath { - path: guardian_rollout_path, - attachment_filename_override: Some(auto_review_rollout_filename( - conversation_id, - )), - }); - } - } - if let Some(extra_log_files) = extra_log_files { - for extra_log_file in extra_log_files { - if seen_attachment_paths.insert(extra_log_file.clone()) { - attachment_paths.push(FeedbackAttachmentPath { - path: extra_log_file, - attachment_filename_override: None, - }); - } - } - } - - let session_source = self.thread_manager.session_source(); - - let upload_result = tokio::task::spawn_blocking(move || { - snapshot.upload_feedback(FeedbackUploadOptions { - classification: &classification, - reason: reason.as_deref(), - tags: tags.as_ref(), - include_logs, - extra_attachment_paths: &attachment_paths, - session_source: Some(session_source), - logs_override: sqlite_feedback_logs, - }) - }) - .await; - - let upload_result = match upload_result { - Ok(result) => result, - Err(join_err) => { - return Err(internal_error(format!( - "failed to upload feedback: {join_err}" - ))); - } - }; - - upload_result.map_err(|err| internal_error(format!("failed to upload feedback: {err}")))?; - Ok(FeedbackUploadResponse { thread_id }) - } - - async fn windows_sandbox_setup_start( - &self, - request_id: ConnectionRequestId, - params: WindowsSandboxSetupStartParams, - ) { - self.outgoing - .send_response( - request_id.clone(), - WindowsSandboxSetupStartResponse { started: true }, - ) - .await; - - let mode = match params.mode { - WindowsSandboxSetupMode::Elevated => CoreWindowsSandboxSetupMode::Elevated, - WindowsSandboxSetupMode::Unelevated => CoreWindowsSandboxSetupMode::Unelevated, - }; - let config = Arc::clone(&self.config); - let config_manager = self.config_manager.clone(); - let command_cwd = params - .cwd - .map(PathBuf::from) - .unwrap_or_else(|| config.cwd.to_path_buf()); - let outgoing = Arc::clone(&self.outgoing); - let connection_id = request_id.connection_id; - - tokio::spawn(async move { - let derived_config = config_manager - .load_for_cwd( - /*request_overrides*/ None, - ConfigOverrides { - cwd: Some(command_cwd.clone()), - ..Default::default() - }, - Some(command_cwd.clone()), - ) - .await; - let setup_result = match derived_config { - Ok(config) => { - let setup_request = WindowsSandboxSetupRequest { - mode, - policy: config - .permissions - .legacy_sandbox_policy(config.cwd.as_path()), - policy_cwd: config.cwd.to_path_buf(), - command_cwd, - env_map: std::env::vars().collect(), - codex_home: config.codex_home.to_path_buf(), - active_profile: config.active_profile.clone(), - }; - codex_core::windows_sandbox::run_windows_sandbox_setup(setup_request).await - } - Err(err) => Err(err.into()), - }; - let notification = WindowsSandboxSetupCompletedNotification { - mode: match mode { - CoreWindowsSandboxSetupMode::Elevated => WindowsSandboxSetupMode::Elevated, - CoreWindowsSandboxSetupMode::Unelevated => WindowsSandboxSetupMode::Unelevated, - }, - success: setup_result.is_ok(), - error: setup_result.err().map(|err| err.to_string()), - }; - outgoing - .send_server_notification_to_connections( - &[connection_id], - ServerNotification::WindowsSandboxSetupCompleted(notification), - ) - .await; - }); - } - - async fn resolve_rollout_path( - &self, - conversation_id: ThreadId, - state_db_ctx: Option<&StateDbHandle>, - ) -> Option { - if let Ok(conversation) = self.thread_manager.get_thread(conversation_id).await - && let Some(rollout_path) = conversation.rollout_path() - { - return Some(rollout_path); - } - - let state_db_ctx = state_db_ctx?; - state_db_ctx - .find_rollout_path_by_id(conversation_id, /*archived_only*/ None) - .await - .unwrap_or_else(|err| { - warn!("failed to resolve rollout path for thread_id={conversation_id}: {err}"); - None - }) - } - - async fn send_invalid_request_error( - &self, - request_id: ConnectionRequestId, - message: impl Into, - ) { - self.outgoing - .send_error(request_id, invalid_request(message)) - .await; - } - - async fn send_internal_error( - &self, - request_id: ConnectionRequestId, - message: impl Into, - ) { - self.outgoing - .send_error(request_id, internal_error(message)) - .await; - } -} - -fn auto_review_rollout_filename(thread_id: ThreadId) -> String { - format!("auto-review-rollout-{thread_id}.jsonl") -} - -fn normalize_thread_list_cwd_filters( - cwd: Option, -) -> Result>, JSONRPCErrorError> { - let Some(cwd) = cwd else { - return Ok(None); - }; - - let cwds = match cwd { - ThreadListCwdFilter::One(cwd) => vec![cwd], - ThreadListCwdFilter::Many(cwds) => cwds, - }; - let mut normalized_cwds = Vec::with_capacity(cwds.len()); - for cwd in cwds { - let cwd = AbsolutePathBuf::relative_to_current_dir(cwd.as_str()) - .map(AbsolutePathBuf::into_path_buf) - .map_err(|err| JSONRPCErrorError { - code: INVALID_PARAMS_ERROR_CODE, - message: format!("invalid thread/list cwd filter `{cwd}`: {err}"), - data: None, - })?; - normalized_cwds.push(cwd); - } - - Ok(Some(normalized_cwds)) -} - -#[cfg(test)] -mod thread_list_cwd_filter_tests { - use super::normalize_thread_list_cwd_filters; - use codex_app_server_protocol::ThreadListCwdFilter; - use codex_utils_absolute_path::AbsolutePathBuf; - use pretty_assertions::assert_eq; - use std::path::PathBuf; - - #[test] - fn normalize_thread_list_cwd_filter_preserves_absolute_paths() { - let cwd = if cfg!(windows) { - String::from(r"C:\srv\repo-b") - } else { - String::from("/srv/repo-b") - }; - - assert_eq!( - normalize_thread_list_cwd_filters(Some(ThreadListCwdFilter::One(cwd.clone()))) - .expect("cwd filter should parse"), - Some(vec![PathBuf::from(cwd)]) - ); - } - - #[test] - fn normalize_thread_list_cwd_filter_resolves_relative_paths_against_server_cwd() - -> std::io::Result<()> { - let expected = AbsolutePathBuf::relative_to_current_dir("repo-b")?.to_path_buf(); - - assert_eq!( - normalize_thread_list_cwd_filters(Some(ThreadListCwdFilter::Many(vec![String::from( - "repo-b" - ),]))) - .expect("cwd filter should parse"), - Some(vec![expected]) - ); - Ok(()) - } -} - -#[allow(clippy::too_many_arguments)] -async fn handle_thread_listener_command( - conversation_id: ThreadId, - conversation: &Arc, - codex_home: &Path, - thread_state_manager: &ThreadStateManager, - thread_state: &Arc>, - thread_watch_manager: &ThreadWatchManager, - outgoing: &Arc, - pending_thread_unloads: &Arc>>, - listener_command: ThreadListenerCommand, -) { - match listener_command { - ThreadListenerCommand::SendThreadResumeResponse(resume_request) => { - handle_pending_thread_resume_request( - conversation_id, - conversation, - codex_home, - thread_state_manager, - thread_state, - thread_watch_manager, - outgoing, - pending_thread_unloads, - *resume_request, - ) - .await; - } - ThreadListenerCommand::EmitThreadGoalUpdated { goal } => { - outgoing - .send_server_notification(ServerNotification::ThreadGoalUpdated( - ThreadGoalUpdatedNotification { - thread_id: conversation_id.to_string(), - turn_id: None, - goal, - }, - )) - .await; - } - ThreadListenerCommand::EmitThreadGoalCleared => { - outgoing - .send_server_notification(ServerNotification::ThreadGoalCleared( - ThreadGoalClearedNotification { - thread_id: conversation_id.to_string(), - }, - )) - .await; - } - ThreadListenerCommand::EmitThreadGoalSnapshot { state_db } => { - send_thread_goal_snapshot_notification(outgoing, conversation_id, &state_db).await; - } - ThreadListenerCommand::ResolveServerRequest { - request_id, - completion_tx, - } => { - resolve_pending_server_request( - conversation_id, - thread_state_manager, - outgoing, - request_id, - ) - .await; - let _ = completion_tx.send(()); - } - } -} - -#[allow(clippy::too_many_arguments)] -#[expect( - clippy::await_holding_invalid_type, - reason = "running-thread resume subscription must be serialized against pending unloads" -)] -async fn handle_pending_thread_resume_request( - conversation_id: ThreadId, - conversation: &Arc, - _codex_home: &Path, - thread_state_manager: &ThreadStateManager, - thread_state: &Arc>, - thread_watch_manager: &ThreadWatchManager, - outgoing: &Arc, - pending_thread_unloads: &Arc>>, - pending: crate::thread_state::PendingThreadResumeRequest, -) { - let active_turn = { - let state = thread_state.lock().await; - state.active_turn_snapshot() - }; - tracing::debug!( - thread_id = %conversation_id, - request_id = ?pending.request_id, - active_turn_present = active_turn.is_some(), - active_turn_id = ?active_turn.as_ref().map(|turn| turn.id.as_str()), - active_turn_status = ?active_turn.as_ref().map(|turn| &turn.status), - "composing running thread resume response" - ); - let has_live_in_progress_turn = - matches!(conversation.agent_status().await, AgentStatus::Running) - || active_turn - .as_ref() - .is_some_and(|turn| matches!(turn.status, TurnStatus::InProgress)); - - let request_id = pending.request_id; - let connection_id = request_id.connection_id; - let mut thread = pending.thread_summary; - if pending.include_turns - && let Err(message) = populate_thread_turns_from_history( - &mut thread, - &pending.history_items, - active_turn.as_ref(), - ) - { - outgoing - .send_error(request_id, internal_error(message)) - .await; - return; - } - - let thread_status = thread_watch_manager - .loaded_status_for_thread(&thread.id) - .await; - - set_thread_status_and_interrupt_stale_turns( - &mut thread, - thread_status, - has_live_in_progress_turn, - ); - - { - let pending_thread_unloads = pending_thread_unloads.lock().await; - if pending_thread_unloads.contains(&conversation_id) { - drop(pending_thread_unloads); - outgoing - .send_error( - request_id, - invalid_request(format!( - "thread {conversation_id} is closing; retry thread/resume after the thread is closed" - )), - ) - .await; - return; - } - if !thread_state_manager - .try_add_connection_to_thread(conversation_id, connection_id) - .await - { - tracing::debug!( - thread_id = %conversation_id, - connection_id = ?connection_id, - "skipping running thread resume for closed connection" - ); - return; - } - } - - if pending.emit_thread_goal_update - && let Err(err) = conversation.apply_goal_resume_runtime_effects().await - { - tracing::warn!("failed to apply goal resume runtime effects: {err}"); - } - - let ThreadConfigSnapshot { - model, - model_provider_id, - service_tier, - approval_policy, - approvals_reviewer, - permission_profile, - active_permission_profile, - cwd, - reasoning_effort, - .. - } = pending.config_snapshot; - let instruction_sources = pending.instruction_sources; - let sandbox = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); - let active_permission_profile = - thread_response_active_permission_profile(active_permission_profile); - - let response = ThreadResumeResponse { - thread, - model, - model_provider: model_provider_id, - service_tier, - cwd, - instruction_sources, - approval_policy: approval_policy.into(), - approvals_reviewer: approvals_reviewer.into(), - sandbox, - permission_profile: Some(permission_profile.into()), - active_permission_profile, - reasoning_effort, - }; - let token_usage_thread = pending.include_turns.then(|| response.thread.clone()); - outgoing.send_response(request_id, response).await; - // Match cold resume: metadata-only resume should attach the listener without - // paying the cost of turn reconstruction for historical usage replay. - if let Some(token_usage_thread) = token_usage_thread { - let token_usage_turn_id = latest_token_usage_turn_id_from_rollout_items( - &pending.history_items, - token_usage_thread.turns.as_slice(), - ); - // Rejoining a loaded thread has the same UI contract as a cold resume, but - // uses the live conversation state instead of reconstructing a new session. - send_thread_token_usage_update_to_connection( - outgoing, - connection_id, - conversation_id, - &token_usage_thread, - conversation.as_ref(), - token_usage_turn_id, - ) - .await; - } - if pending.emit_thread_goal_update { - if let Some(state_db) = pending.thread_goal_state_db { - send_thread_goal_snapshot_notification(outgoing, conversation_id, &state_db).await; - } else { - tracing::warn!( - thread_id = %conversation_id, - "state db unavailable when reading thread goal for running thread resume" - ); - } - } - outgoing - .replay_requests_to_connection_for_thread(connection_id, conversation_id) - .await; - // App-server owns resume response and snapshot ordering, so wait until - // replay completes before letting core start goal continuation. - if pending.emit_thread_goal_update - && let Err(err) = conversation.continue_active_goal_if_idle().await - { - tracing::warn!("failed to continue active goal after running-thread resume: {err}"); - } -} - -async fn send_thread_goal_snapshot_notification( - outgoing: &Arc, - thread_id: ThreadId, - state_db: &StateDbHandle, -) { - match state_db.get_thread_goal(thread_id).await { - Ok(Some(goal)) => { - outgoing - .send_server_notification(ServerNotification::ThreadGoalUpdated( - ThreadGoalUpdatedNotification { - thread_id: thread_id.to_string(), - turn_id: None, - goal: api_thread_goal_from_state(goal), - }, - )) - .await; - } - Ok(None) => { - outgoing - .send_server_notification(ServerNotification::ThreadGoalCleared( - ThreadGoalClearedNotification { - thread_id: thread_id.to_string(), - }, - )) - .await; - } - Err(err) => { - tracing::warn!( - thread_id = %thread_id, - "failed to read thread goal for resume snapshot: {err}" - ); - } - } -} - -fn populate_thread_turns_from_history( - thread: &mut Thread, - items: &[RolloutItem], - active_turn: Option<&Turn>, -) -> std::result::Result<(), String> { - let mut turns = build_turns_from_rollout_items(items); - if let Some(active_turn) = active_turn { - merge_turn_history_with_active_turn(&mut turns, active_turn.clone()); - } - thread.turns = turns; - Ok(()) -} - -async fn resolve_pending_server_request( - conversation_id: ThreadId, - thread_state_manager: &ThreadStateManager, - outgoing: &Arc, - request_id: RequestId, -) { - let thread_id = conversation_id.to_string(); - let subscribed_connection_ids = thread_state_manager - .subscribed_connection_ids(conversation_id) - .await; - let outgoing = ThreadScopedOutgoingMessageSender::new( - outgoing.clone(), - subscribed_connection_ids, - conversation_id, - ); - outgoing - .send_server_notification(ServerNotification::ServerRequestResolved( - ServerRequestResolvedNotification { - thread_id, - request_id, - }, - )) - .await; -} - -fn merge_turn_history_with_active_turn(turns: &mut Vec, active_turn: Turn) { - turns.retain(|turn| turn.id != active_turn.id); - turns.push(active_turn); -} - -fn set_thread_status_and_interrupt_stale_turns( - thread: &mut Thread, - loaded_status: ThreadStatus, - has_live_in_progress_turn: bool, -) { - let status = resolve_thread_status(loaded_status, has_live_in_progress_turn); - if !matches!(status, ThreadStatus::Active { .. }) { - for turn in &mut thread.turns { - if matches!(turn.status, TurnStatus::InProgress) { - turn.status = TurnStatus::Interrupted; - } - } - } - thread.status = status; -} - -fn collect_resume_override_mismatches( - request: &ThreadResumeParams, - config_snapshot: &ThreadConfigSnapshot, -) -> Vec { - let mut mismatch_details = Vec::new(); - - if let Some(requested_model) = request.model.as_deref() - && requested_model != config_snapshot.model - { - mismatch_details.push(format!( - "model requested={requested_model} active={}", - config_snapshot.model - )); - } - if let Some(requested_provider) = request.model_provider.as_deref() - && requested_provider != config_snapshot.model_provider_id - { - mismatch_details.push(format!( - "model_provider requested={requested_provider} active={}", - config_snapshot.model_provider_id - )); - } - if let Some(requested_service_tier) = request.service_tier.as_ref() - && requested_service_tier != &config_snapshot.service_tier - { - mismatch_details.push(format!( - "service_tier requested={requested_service_tier:?} active={:?}", - config_snapshot.service_tier - )); - } - if let Some(requested_cwd) = request.cwd.as_deref() { - let requested_cwd_path = std::path::PathBuf::from(requested_cwd); - if requested_cwd_path != config_snapshot.cwd.as_path() { - mismatch_details.push(format!( - "cwd requested={} active={}", - requested_cwd_path.display(), - config_snapshot.cwd.display() - )); - } - } - if let Some(requested_approval) = request.approval_policy.as_ref() { - let active_approval: AskForApproval = config_snapshot.approval_policy.into(); - if requested_approval != &active_approval { - mismatch_details.push(format!( - "approval_policy requested={requested_approval:?} active={active_approval:?}" - )); - } - } - if let Some(requested_review_policy) = request.approvals_reviewer.as_ref() { - let active_review_policy: codex_app_server_protocol::ApprovalsReviewer = - config_snapshot.approvals_reviewer.into(); - if requested_review_policy != &active_review_policy { - mismatch_details.push(format!( - "approvals_reviewer requested={requested_review_policy:?} active={active_review_policy:?}" - )); - } - } - if let Some(requested_sandbox) = request.sandbox.as_ref() { - let active_sandbox = config_snapshot.sandbox_policy(); - let sandbox_matches = matches!( - (requested_sandbox, &active_sandbox), - ( - SandboxMode::ReadOnly, - codex_protocol::protocol::SandboxPolicy::ReadOnly { .. } - ) | ( - SandboxMode::WorkspaceWrite, - codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } - ) | ( - SandboxMode::DangerFullAccess, - codex_protocol::protocol::SandboxPolicy::DangerFullAccess - ) | ( - SandboxMode::DangerFullAccess, - codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. } - ) - ); - if !sandbox_matches { - mismatch_details.push(format!( - "sandbox requested={requested_sandbox:?} active={active_sandbox:?}" - )); - } - } - if request.permissions.is_some() { - mismatch_details.push(format!( - "permissions override was provided and ignored while running; active={:?}", - config_snapshot.active_permission_profile - )); - } - if let Some(requested_personality) = request.personality.as_ref() - && config_snapshot.personality.as_ref() != Some(requested_personality) - { - mismatch_details.push(format!( - "personality requested={requested_personality:?} active={:?}", - config_snapshot.personality - )); - } - - if request.config.is_some() { - mismatch_details - .push("config overrides were provided and ignored while running".to_string()); - } - if request.base_instructions.is_some() { - mismatch_details - .push("baseInstructions override was provided and ignored while running".to_string()); - } - if request.developer_instructions.is_some() { - mismatch_details.push( - "developerInstructions override was provided and ignored while running".to_string(), - ); - } - if request.persist_extended_history { - mismatch_details.push( - "persistExtendedHistory override was provided and ignored while running".to_string(), - ); - } - - mismatch_details -} - -fn merge_persisted_resume_metadata( - request_overrides: &mut Option>, - typesafe_overrides: &mut ConfigOverrides, - persisted_metadata: &ThreadMetadata, -) { - if has_model_resume_override(request_overrides.as_ref(), typesafe_overrides) { - return; - } - - typesafe_overrides.model = persisted_metadata.model.clone(); - typesafe_overrides.model_provider = Some(persisted_metadata.model_provider.clone()); - - if let Some(reasoning_effort) = persisted_metadata.reasoning_effort { - request_overrides.get_or_insert_with(HashMap::new).insert( - "model_reasoning_effort".to_string(), - serde_json::Value::String(reasoning_effort.to_string()), - ); - } -} - -fn has_model_resume_override( - request_overrides: Option<&HashMap>, - typesafe_overrides: &ConfigOverrides, -) -> bool { - typesafe_overrides.model.is_some() - || typesafe_overrides.model_provider.is_some() - || request_overrides.is_some_and(|overrides| overrides.contains_key("model")) - || request_overrides - .is_some_and(|overrides| overrides.contains_key("model_reasoning_effort")) -} - -fn skills_to_info( - skills: &[codex_core::skills::SkillMetadata], - disabled_paths: &std::collections::HashSet, -) -> Vec { - skills - .iter() - .map(|skill| { - let enabled = !disabled_paths.contains(&skill.path_to_skills_md); - codex_app_server_protocol::SkillMetadata { - name: skill.name.clone(), - description: skill.description.clone(), - short_description: skill.short_description.clone(), - interface: skill.interface.clone().map(|interface| { - codex_app_server_protocol::SkillInterface { - display_name: interface.display_name, - short_description: interface.short_description, - icon_small: interface.icon_small, - icon_large: interface.icon_large, - brand_color: interface.brand_color, - default_prompt: interface.default_prompt, - } - }), - dependencies: skill.dependencies.clone().map(|dependencies| { - codex_app_server_protocol::SkillDependencies { - tools: dependencies - .tools - .into_iter() - .map(|tool| codex_app_server_protocol::SkillToolDependency { - r#type: tool.r#type, - value: tool.value, - description: tool.description, - transport: tool.transport, - command: tool.command, - url: tool.url, - }) - .collect(), - } - }), - path: skill.path_to_skills_md.clone(), - scope: skill.scope.into(), - enabled, - } - }) - .collect() -} - -fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec { - hooks - .iter() - .map(|hook| HookMetadata { - key: hook.key.clone(), - event_name: hook.event_name.into(), - handler_type: hook.handler_type.into(), - matcher: hook.matcher.clone(), - command: hook.command.clone(), - timeout_sec: hook.timeout_sec, - status_message: hook.status_message.clone(), - source_path: hook.source_path.clone(), - source: hook.source.into(), - plugin_id: hook.plugin_id.clone(), - display_order: hook.display_order, - enabled: hook.enabled, - is_managed: hook.is_managed, - }) - .collect() -} - -fn plugin_skills_to_info( - skills: &[codex_core::skills::SkillMetadata], - disabled_skill_paths: &std::collections::HashSet, -) -> Vec { - skills - .iter() - .map(|skill| SkillSummary { - name: skill.name.clone(), - description: skill.description.clone(), - short_description: skill.short_description.clone(), - interface: skill.interface.clone().map(|interface| { - codex_app_server_protocol::SkillInterface { - display_name: interface.display_name, - short_description: interface.short_description, - icon_small: interface.icon_small, - icon_large: interface.icon_large, - brand_color: interface.brand_color, - default_prompt: interface.default_prompt, - } - }), - path: Some(skill.path_to_skills_md.clone()), - enabled: !disabled_skill_paths.contains(&skill.path_to_skills_md), - }) - .collect() -} - -fn local_plugin_interface_to_info(interface: PluginManifestInterface) -> PluginInterface { - PluginInterface { - display_name: interface.display_name, - short_description: interface.short_description, - long_description: interface.long_description, - developer_name: interface.developer_name, - category: interface.category, - capabilities: interface.capabilities, - website_url: interface.website_url, - privacy_policy_url: interface.privacy_policy_url, - terms_of_service_url: interface.terms_of_service_url, - default_prompt: interface.default_prompt, - brand_color: interface.brand_color, - composer_icon: interface.composer_icon, - composer_icon_url: None, - logo: interface.logo, - logo_url: None, - screenshots: interface.screenshots, - screenshot_urls: Vec::new(), - } -} - -fn marketplace_plugin_source_to_info(source: MarketplacePluginSource) -> PluginSource { - match source { - MarketplacePluginSource::Local { path } => PluginSource::Local { path }, - MarketplacePluginSource::Git { - url, - path, - ref_name, - sha, - } => PluginSource::Git { - url, - path, - ref_name, - sha, - }, - } -} - -fn errors_to_info( - errors: &[codex_core::skills::SkillError], -) -> Vec { - errors - .iter() - .map(|err| codex_app_server_protocol::SkillErrorInfo { - path: err.path.to_path_buf(), - message: err.message.clone(), - }) - .collect() -} - -fn cloud_requirements_load_error(err: &std::io::Error) -> Option<&CloudRequirementsLoadError> { - let mut current: Option<&(dyn std::error::Error + 'static)> = err - .get_ref() - .map(|source| source as &(dyn std::error::Error + 'static)); - while let Some(source) = current { - if let Some(cloud_error) = source.downcast_ref::() { - return Some(cloud_error); - } - current = source.source(); - } - None -} - -fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError { - let data = cloud_requirements_load_error(err).map(|cloud_error| { - let mut data = serde_json::json!({ - "reason": "cloudRequirements", - "errorCode": format!("{:?}", cloud_error.code()), - "detail": cloud_error.to_string(), - }); - if let Some(status_code) = cloud_error.status_code() { - data["statusCode"] = serde_json::json!(status_code); - } - if cloud_error.code() == CloudRequirementsLoadErrorCode::Auth { - data["action"] = serde_json::json!("relogin"); - } - data - }); - - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("failed to load configuration: {err}"), - data, - } -} - -fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> { - let mut seen = HashSet::new(); - for tool in tools { - let name = tool.name.trim(); - if name.is_empty() { - return Err("dynamic tool name must not be empty".to_string()); - } - if name != tool.name { - return Err(format!( - "dynamic tool name has leading/trailing whitespace: {}", - tool.name - )); - } - if name == "mcp" || name.starts_with("mcp__") { - return Err(format!("dynamic tool name is reserved: {name}")); - } - let namespace = tool.namespace.as_deref().map(str::trim); - if let Some(namespace) = namespace { - if namespace.is_empty() { - return Err(format!( - "dynamic tool namespace must not be empty for {name}" - )); - } - if Some(namespace) != tool.namespace.as_deref() { - return Err(format!( - "dynamic tool namespace has leading/trailing whitespace for {name}: {namespace}", - )); - } - if namespace == "mcp" || namespace.starts_with("mcp__") { - return Err(format!( - "dynamic tool namespace is reserved for {name}: {namespace}" - )); - } - } - if !seen.insert((namespace, name)) { - if let Some(namespace) = namespace { - return Err(format!( - "duplicate dynamic tool name in namespace {namespace}: {name}" - )); - } - return Err(format!("duplicate dynamic tool name: {name}")); - } - if tool.defer_loading && namespace.is_none() { - return Err(format!( - "deferred dynamic tool must include a namespace: {name}" - )); - } - - if let Err(err) = codex_tools::parse_tool_input_schema(&tool.input_schema) { - return Err(format!( - "dynamic tool input schema is not supported for {name}: {err}" - )); - } - } - Ok(()) -} - -async fn read_summary_from_state_db_context_by_thread_id( - state_db_ctx: Option<&StateDbHandle>, - thread_id: ThreadId, -) -> Option { - let state_db_ctx = state_db_ctx?; - - let metadata = match state_db_ctx.get_thread(thread_id).await { - Ok(Some(metadata)) => metadata, - Ok(None) | Err(_) => return None, - }; - Some(summary_from_thread_metadata(&metadata)) -} - -async fn title_from_state_db(config: &Config, thread_id: ThreadId) -> Option { - if let Some(state_db_ctx) = open_state_db_for_direct_thread_lookup(config).await - && let Some(metadata) = state_db_ctx.get_thread(thread_id).await.ok().flatten() - && let Some(title) = distinct_title(&metadata) - { - return Some(title); - } - find_thread_name_by_id(&config.codex_home, &thread_id) - .await - .ok() - .flatten() -} - -async fn open_state_db_for_direct_thread_lookup(config: &Config) -> Option { - StateRuntime::init(config.sqlite_home.clone(), config.model_provider_id.clone()) - .await - .ok() -} - -fn invalid_request(message: impl Into) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: message.into(), - data: None, - } -} - -fn internal_error(message: impl Into) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: message.into(), - data: None, - } -} - -fn parse_thread_id_for_request(thread_id: &str) -> Result { - ThreadId::from_string(thread_id) - .map_err(|err| invalid_request(format!("invalid thread id: {err}"))) -} - -fn non_empty_title(metadata: &ThreadMetadata) -> Option { - let title = metadata.title.trim(); - (!title.is_empty()).then(|| title.to_string()) -} - -fn distinct_title(metadata: &ThreadMetadata) -> Option { - let title = non_empty_title(metadata)?; - if metadata.first_user_message.as_deref().map(str::trim) == Some(title.as_str()) { - None - } else { - Some(title) - } -} - -fn set_thread_name_from_title(thread: &mut Thread, title: String) { - if title.trim().is_empty() || thread.preview.trim() == title.trim() { - return; - } - thread.name = Some(title); -} - -fn thread_store_list_error(err: ThreadStoreError) -> JSONRPCErrorError { - match err { - ThreadStoreError::InvalidRequest { message } => JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message, - data: None, - }, - err => JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to list threads: {err}"), - data: None, - }, - } -} - -fn thread_store_resume_read_error(err: ThreadStoreError) -> JSONRPCErrorError { - match err { - ThreadStoreError::InvalidRequest { message } => JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message, - data: None, - }, - ThreadStoreError::ThreadNotFound { thread_id } => JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("no rollout found for thread id {thread_id}"), - data: None, - }, - err => JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to read thread: {err}"), - data: None, - }, - } -} - -fn thread_turns_list_history_load_error( - thread_id: ThreadId, - err: ThreadStoreError, -) -> ThreadReadViewError { - match err { - ThreadStoreError::InvalidRequest { message } - if message.starts_with("failed to resolve rollout path `") => - { - ThreadReadViewError::InvalidRequest(format!( - "thread {thread_id} is not materialized yet; thread/turns/list is unavailable before first user message" - )) - } - ThreadStoreError::InvalidRequest { message } => { - ThreadReadViewError::InvalidRequest(message) - } - err => ThreadReadViewError::Internal(format!( - "failed to load thread history for thread {thread_id}: {err}" - )), - } -} - -fn thread_read_history_load_error( - thread_id: ThreadId, - err: ThreadStoreError, -) -> ThreadReadViewError { - match err { - ThreadStoreError::InvalidRequest { message } - if message.starts_with("failed to resolve rollout path `") => - { - ThreadReadViewError::InvalidRequest(format!( - "thread {thread_id} is not materialized yet; includeTurns is unavailable before first user message" - )) - } - ThreadStoreError::ThreadNotFound { - thread_id: missing_thread_id, - } if missing_thread_id == thread_id => ThreadReadViewError::InvalidRequest(format!( - "thread {thread_id} is not materialized yet; includeTurns is unavailable before first user message" - )), - ThreadStoreError::InvalidRequest { message } => { - ThreadReadViewError::InvalidRequest(message) - } - err => ThreadReadViewError::Internal(format!( - "failed to load thread history for thread {thread_id}: {err}" - )), - } -} - -fn conversation_summary_thread_id_read_error( - conversation_id: ThreadId, - err: ThreadStoreError, -) -> JSONRPCErrorError { - let no_rollout_message = format!("no rollout found for thread id {conversation_id}"); - match err { - ThreadStoreError::InvalidRequest { message } if message == no_rollout_message => { - conversation_summary_not_found_error(conversation_id) - } - ThreadStoreError::ThreadNotFound { thread_id } if thread_id == conversation_id => { - conversation_summary_not_found_error(conversation_id) - } - ThreadStoreError::InvalidRequest { message } => JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message, - data: None, - }, - err => JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to load conversation summary for {conversation_id}: {err}"), - data: None, - }, - } -} - -fn conversation_summary_not_found_error(conversation_id: ThreadId) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("no rollout found for conversation id {conversation_id}"), - data: None, - } -} - -fn conversation_summary_rollout_path_read_error( - path: &Path, - err: ThreadStoreError, -) -> JSONRPCErrorError { - match err { - ThreadStoreError::InvalidRequest { message } => JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message, - data: None, - }, - err => JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!( - "failed to load conversation summary from {}: {}", - path.display(), - err - ), - data: None, - }, - } -} - -fn thread_store_write_error(operation: &str, err: ThreadStoreError) -> JSONRPCErrorError { - match err { - ThreadStoreError::ThreadNotFound { thread_id } => JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("thread not found: {thread_id}"), - data: None, - }, - ThreadStoreError::InvalidRequest { message } => JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message, - data: None, - }, - err => JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to {operation}: {err}"), - data: None, - }, - } -} - -fn thread_from_stored_thread( - thread: StoredThread, - fallback_provider: &str, - fallback_cwd: &AbsolutePathBuf, -) -> (Thread, Option) { - let path = thread.rollout_path; - let git_info = thread.git_info.map(|info| ApiGitInfo { - sha: info.commit_hash.map(|sha| sha.0), - branch: info.branch, - origin_url: info.repository_url, - }); - let cwd = AbsolutePathBuf::relative_to_current_dir(path_utils::normalize_for_native_workdir( - thread.cwd, - )) - .unwrap_or_else(|err| { - warn!("failed to normalize thread cwd while reading stored thread: {err}"); - fallback_cwd.clone() - }); - let source = with_thread_spawn_agent_metadata( - thread.source, - thread.agent_nickname.clone(), - thread.agent_role.clone(), - ); - let history = thread.history; - let thread = Thread { - id: thread.thread_id.to_string(), - forked_from_id: thread.forked_from_id.map(|id| id.to_string()), - preview: thread.first_user_message.unwrap_or(thread.preview), - ephemeral: false, - model_provider: if thread.model_provider.is_empty() { - fallback_provider.to_string() - } else { - thread.model_provider - }, - created_at: thread.created_at.timestamp(), - updated_at: thread.updated_at.timestamp(), - status: ThreadStatus::NotLoaded, - path, - cwd, - cli_version: thread.cli_version, - agent_nickname: source.get_nickname(), - agent_role: source.get_agent_role(), - source: source.into(), - git_info, - name: thread.name, - turns: Vec::new(), - }; - (thread, history) -} - -fn thread_store_archive_error(operation: &str, err: ThreadStoreError) -> JSONRPCErrorError { - match err { - ThreadStoreError::InvalidRequest { message } => JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message, - data: None, - }, - err => JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to {operation} thread: {err}"), - data: None, - }, - } -} - -const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId"; - -fn with_mcp_tool_call_thread_id_meta( - meta: Option, - thread_id: &str, -) -> Option { - match meta { - Some(serde_json::Value::Object(mut map)) => { - map.insert( - MCP_TOOL_THREAD_ID_META_KEY.to_string(), - serde_json::Value::String(thread_id.to_string()), - ); - Some(serde_json::Value::Object(map)) - } - None => { - let mut map = serde_json::Map::new(); - map.insert( - MCP_TOOL_THREAD_ID_META_KEY.to_string(), - serde_json::Value::String(thread_id.to_string()), - ); - Some(serde_json::Value::Object(map)) - } - other => other, - } -} - -fn summary_from_stored_thread( - thread: StoredThread, - fallback_provider: &str, -) -> Option { - let path = thread.rollout_path?; - let source = with_thread_spawn_agent_metadata( - thread.source, - thread.agent_nickname.clone(), - thread.agent_role.clone(), - ); - let git_info = thread.git_info.map(|git| ConversationGitInfo { - sha: git.commit_hash.map(|sha| sha.0), - branch: git.branch, - origin_url: git.repository_url, - }); - Some(ConversationSummary { - conversation_id: thread.thread_id, - path, - preview: thread.first_user_message.unwrap_or(thread.preview), - // Preserve millisecond precision from the thread store so thread/list cursors - // round-trip the same ordering key used by pagination queries. - timestamp: Some( - thread - .created_at - .to_rfc3339_opts(SecondsFormat::Millis, true), - ), - updated_at: Some( - thread - .updated_at - .to_rfc3339_opts(SecondsFormat::Millis, true), - ), - model_provider: if thread.model_provider.is_empty() { - fallback_provider.to_string() - } else { - thread.model_provider - }, - cwd: thread.cwd, - cli_version: thread.cli_version, - source, - git_info, - }) -} - -#[allow(clippy::too_many_arguments)] -fn summary_from_state_db_metadata( - conversation_id: ThreadId, - path: PathBuf, - first_user_message: Option, - timestamp: String, - updated_at: String, - model_provider: String, - cwd: PathBuf, - cli_version: String, - source: String, - agent_nickname: Option, - agent_role: Option, - git_sha: Option, - git_branch: Option, - git_origin_url: Option, -) -> ConversationSummary { - let preview = first_user_message.unwrap_or_default(); - let source = serde_json::from_str(&source) - .or_else(|_| serde_json::from_value(serde_json::Value::String(source.clone()))) - .unwrap_or(codex_protocol::protocol::SessionSource::Unknown); - let source = with_thread_spawn_agent_metadata(source, agent_nickname, agent_role); - let git_info = if git_sha.is_none() && git_branch.is_none() && git_origin_url.is_none() { - None - } else { - Some(ConversationGitInfo { - sha: git_sha, - branch: git_branch, - origin_url: git_origin_url, - }) - }; - ConversationSummary { - conversation_id, - path, - preview, - timestamp: Some(timestamp), - updated_at: Some(updated_at), - model_provider, - cwd, - cli_version, - source, - git_info, - } -} - -fn summary_from_thread_metadata(metadata: &ThreadMetadata) -> ConversationSummary { - summary_from_state_db_metadata( - metadata.id, - metadata.rollout_path.clone(), - metadata.first_user_message.clone(), - metadata - .created_at - .to_rfc3339_opts(SecondsFormat::Secs, true), - metadata - .updated_at - .to_rfc3339_opts(SecondsFormat::Secs, true), - metadata.model_provider.clone(), - metadata.cwd.clone(), - metadata.cli_version.clone(), - metadata.source.clone(), - metadata.agent_nickname.clone(), - metadata.agent_role.clone(), - metadata.git_sha.clone(), - metadata.git_branch.clone(), - metadata.git_origin_url.clone(), - ) -} - -pub(crate) async fn read_summary_from_rollout( - path: &Path, - fallback_provider: &str, -) -> std::io::Result { - let head = read_head_for_summary(path).await?; - - let Some(first) = head.first() else { - return Err(IoError::other(format!( - "rollout at {} is empty", - path.display() - ))); - }; - - let session_meta_line = - serde_json::from_value::(first.clone()).map_err(|_| { - IoError::other(format!( - "rollout at {} does not start with session metadata", - path.display() - )) - })?; - let SessionMetaLine { - meta: session_meta, - git, - } = session_meta_line; - let mut session_meta = session_meta; - session_meta.source = with_thread_spawn_agent_metadata( - session_meta.source.clone(), - session_meta.agent_nickname.clone(), - session_meta.agent_role.clone(), - ); - - let created_at = if session_meta.timestamp.is_empty() { - None - } else { - Some(session_meta.timestamp.as_str()) - }; - let updated_at = read_updated_at(path, created_at).await; - if let Some(summary) = extract_conversation_summary( - path.to_path_buf(), - &head, - &session_meta, - git.as_ref(), - fallback_provider, - updated_at.clone(), - ) { - return Ok(summary); - } - - let timestamp = if session_meta.timestamp.is_empty() { - None - } else { - Some(session_meta.timestamp.clone()) - }; - let model_provider = session_meta - .model_provider - .clone() - .unwrap_or_else(|| fallback_provider.to_string()); - let git_info = git.as_ref().map(map_git_info); - let updated_at = updated_at.or_else(|| timestamp.clone()); - - Ok(ConversationSummary { - conversation_id: session_meta.id, - timestamp, - updated_at, - path: path.to_path_buf(), - preview: String::new(), - model_provider, - cwd: session_meta.cwd, - cli_version: session_meta.cli_version, - source: session_meta.source, - git_info, - }) -} - -pub(crate) async fn read_rollout_items_from_rollout( - path: &Path, -) -> std::io::Result> { - let items = match RolloutRecorder::get_rollout_history(path).await? { - InitialHistory::New | InitialHistory::Cleared => Vec::new(), - InitialHistory::Forked(items) => items, - InitialHistory::Resumed(resumed) => resumed.history, - }; - - Ok(items) -} - -fn extract_conversation_summary( - path: PathBuf, - head: &[serde_json::Value], - session_meta: &SessionMeta, - git: Option<&CoreGitInfo>, - fallback_provider: &str, - updated_at: Option, -) -> Option { - let preview = head - .iter() - .filter_map(|value| serde_json::from_value::(value.clone()).ok()) - .find_map(|item| match codex_core::parse_turn_item(&item) { - Some(TurnItem::UserMessage(user)) => Some(user.message()), - _ => None, - })?; - - let preview = match preview.find(USER_MESSAGE_BEGIN) { - Some(idx) => preview[idx + USER_MESSAGE_BEGIN.len()..].trim(), - None => preview.as_str(), - }; - - let timestamp = if session_meta.timestamp.is_empty() { - None - } else { - Some(session_meta.timestamp.clone()) - }; - let conversation_id = session_meta.id; - let model_provider = session_meta - .model_provider - .clone() - .unwrap_or_else(|| fallback_provider.to_string()); - let git_info = git.map(map_git_info); - let updated_at = updated_at.or_else(|| timestamp.clone()); - - Some(ConversationSummary { - conversation_id, - timestamp, - updated_at, - path, - preview: preview.to_string(), - model_provider, - cwd: session_meta.cwd.clone(), - cli_version: session_meta.cli_version.clone(), - source: session_meta.source.clone(), - git_info, - }) -} - -fn map_git_info(git_info: &CoreGitInfo) -> ConversationGitInfo { - ConversationGitInfo { - sha: git_info.commit_hash.as_ref().map(|sha| sha.0.clone()), - branch: git_info.branch.clone(), - origin_url: git_info.repository_url.clone(), - } -} - -#[cfg(test)] -async fn forked_from_id_from_rollout(path: &Path) -> Option { - codex_core::read_session_meta_line(path) - .await - .ok() - .and_then(|meta_line| meta_line.meta.forked_from_id) - .map(|thread_id| thread_id.to_string()) -} - -fn preview_from_rollout_items(items: &[RolloutItem]) -> String { - items - .iter() - .find_map(|item| match item { - RolloutItem::ResponseItem(item) => match codex_core::parse_turn_item(item) { - Some(codex_protocol::items::TurnItem::UserMessage(user)) => Some(user.message()), - _ => None, - }, - _ => None, - }) - .map(|preview| match preview.find(USER_MESSAGE_BEGIN) { - Some(idx) => preview[idx + USER_MESSAGE_BEGIN.len()..].trim().to_string(), - None => preview, - }) - .unwrap_or_default() -} - -fn with_thread_spawn_agent_metadata( - source: codex_protocol::protocol::SessionSource, - agent_nickname: Option, - agent_role: Option, -) -> codex_protocol::protocol::SessionSource { - if agent_nickname.is_none() && agent_role.is_none() { - return source; - } - - match source { - codex_protocol::protocol::SessionSource::SubAgent( - codex_protocol::protocol::SubAgentSource::ThreadSpawn { - parent_thread_id, - depth, - agent_path, - agent_nickname: existing_agent_nickname, - agent_role: existing_agent_role, - }, - ) => codex_protocol::protocol::SessionSource::SubAgent( - codex_protocol::protocol::SubAgentSource::ThreadSpawn { - parent_thread_id, - depth, - agent_path, - agent_nickname: agent_nickname.or(existing_agent_nickname), - agent_role: agent_role.or(existing_agent_role), - }, - ), - _ => source, - } -} - -fn thread_response_active_permission_profile( - active_permission_profile: Option, -) -> Option { - active_permission_profile.map(Into::into) -} - -fn apply_permission_profile_selection_to_config_overrides( - overrides: &mut ConfigOverrides, - permissions: Option, -) { - let Some(PermissionProfileSelectionParams::Profile { id, modifications }) = permissions else { - return; - }; - overrides.default_permissions = Some(id); - overrides - .additional_writable_roots - .extend(modifications.unwrap_or_default().into_iter().map( - |modification| match modification { - PermissionProfileModificationParams::AdditionalWritableRoot { path } => { - path.to_path_buf() - } - }, - )); -} - -fn thread_response_sandbox_policy( - permission_profile: &codex_protocol::models::PermissionProfile, - cwd: &Path, -) -> codex_app_server_protocol::SandboxPolicy { - let file_system_policy = permission_profile.file_system_sandbox_policy(); - let sandbox_policy = codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( - permission_profile, - &file_system_policy, - permission_profile.network_sandbox_policy(), - cwd, - ); - sandbox_policy.into() -} - -fn requested_permissions_trust_project(overrides: &ConfigOverrides, cwd: &Path) -> bool { - if matches!( - overrides.sandbox_mode, - Some( - codex_protocol::config_types::SandboxMode::WorkspaceWrite - | codex_protocol::config_types::SandboxMode::DangerFullAccess - ) - ) { - return true; - } - - if matches!( - overrides.default_permissions.as_deref(), - Some(":workspace" | ":danger-no-sandbox") - ) { - return true; - } - - overrides - .permission_profile - .as_ref() - .is_some_and(|profile| permission_profile_trusts_project(profile, cwd)) -} - -fn permission_profile_trusts_project( - profile: &codex_protocol::models::PermissionProfile, - cwd: &Path, -) -> bool { - match profile { - codex_protocol::models::PermissionProfile::Disabled - | codex_protocol::models::PermissionProfile::External { .. } => true, - codex_protocol::models::PermissionProfile::Managed { .. } => profile - .file_system_sandbox_policy() - .can_write_path_with_cwd(cwd, cwd), - } -} - -fn parse_datetime(timestamp: Option<&str>) -> Option> { - timestamp.and_then(|ts| { - chrono::DateTime::parse_from_rfc3339(ts) - .ok() - .map(|dt| dt.with_timezone(&chrono::Utc)) - }) -} - -async fn read_updated_at(path: &Path, created_at: Option<&str>) -> Option { - let updated_at = tokio::fs::metadata(path) - .await - .ok() - .and_then(|meta| meta.modified().ok()) - .map(|modified| { - let updated_at: DateTime = modified.into(); - updated_at.to_rfc3339_opts(SecondsFormat::Millis, true) - }); - updated_at.or_else(|| created_at.map(str::to_string)) -} - -fn build_thread_from_snapshot( - thread_id: ThreadId, - config_snapshot: &ThreadConfigSnapshot, - path: Option, -) -> Thread { - let now = time::OffsetDateTime::now_utc().unix_timestamp(); - Thread { - id: thread_id.to_string(), - forked_from_id: None, - preview: String::new(), - ephemeral: config_snapshot.ephemeral, - model_provider: config_snapshot.model_provider_id.clone(), - created_at: now, - updated_at: now, - status: ThreadStatus::NotLoaded, - path, - cwd: config_snapshot.cwd.clone(), - cli_version: env!("CARGO_PKG_VERSION").to_string(), - agent_nickname: config_snapshot.session_source.get_nickname(), - agent_role: config_snapshot.session_source.get_agent_role(), - source: config_snapshot.session_source.clone().into(), - git_info: None, - name: None, - turns: Vec::new(), - } -} - -fn build_thread_from_loaded_snapshot( - thread_id: ThreadId, - config_snapshot: &ThreadConfigSnapshot, - loaded_thread: &CodexThread, -) -> Thread { - build_thread_from_snapshot(thread_id, config_snapshot, loaded_thread.rollout_path()) -} - -fn thread_started_notification(mut thread: Thread) -> ThreadStartedNotification { - thread.turns.clear(); - ThreadStartedNotification { thread } -} - -pub(crate) fn summary_to_thread( - summary: ConversationSummary, - fallback_cwd: &AbsolutePathBuf, -) -> Thread { - let ConversationSummary { - conversation_id, - path, - preview, - timestamp, - updated_at, - model_provider, - cwd, - cli_version, - source, - git_info, - } = summary; - - let created_at = parse_datetime(timestamp.as_deref()); - let updated_at = parse_datetime(updated_at.as_deref()).or(created_at); - let git_info = git_info.map(|info| ApiGitInfo { - sha: info.sha, - branch: info.branch, - origin_url: info.origin_url, - }); - let cwd = - AbsolutePathBuf::relative_to_current_dir(path_utils::normalize_for_native_workdir(cwd)) - .unwrap_or_else(|err| { - warn!( - path = %path.display(), - "failed to normalize thread cwd while summarizing thread: {err}" - ); - fallback_cwd.clone() - }); - - Thread { - id: conversation_id.to_string(), - forked_from_id: None, - preview, - ephemeral: false, - model_provider, - created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0), - updated_at: updated_at.map(|dt| dt.timestamp()).unwrap_or(0), - status: ThreadStatus::NotLoaded, - path: Some(path), - cwd, - cli_version, - agent_nickname: source.get_nickname(), - agent_role: source.get_agent_role(), - source: source.into(), - git_info, - name: None, - turns: Vec::new(), - } -} - -fn thread_backwards_cursor_for_sort_key( - thread: &StoredThread, - sort_key: StoreThreadSortKey, - sort_direction: SortDirection, -) -> Option { - let timestamp = match sort_key { - StoreThreadSortKey::CreatedAt => thread.created_at, - StoreThreadSortKey::UpdatedAt => thread.updated_at, - }; - // The state DB stores unique millisecond timestamps. Offset the reverse cursor by one - // millisecond so the opposite-direction query includes the page anchor. - let timestamp = match sort_direction { - SortDirection::Asc => timestamp.checked_add_signed(ChronoDuration::milliseconds(1))?, - SortDirection::Desc => timestamp.checked_sub_signed(ChronoDuration::milliseconds(1))?, - }; - Some(timestamp.to_rfc3339_opts(SecondsFormat::Millis, true)) -} - -struct ThreadTurnsPage { - turns: Vec, - next_cursor: Option, - backwards_cursor: Option, -} - -#[derive(serde::Serialize, serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct ThreadTurnsCursor { - turn_id: String, - include_anchor: bool, -} - -fn paginate_thread_turns( - turns: Vec, - cursor: Option<&str>, - limit: Option, - sort_direction: SortDirection, -) -> Result { - if turns.is_empty() { - return Ok(ThreadTurnsPage { - turns: Vec::new(), - next_cursor: None, - backwards_cursor: None, - }); - } - - let anchor = cursor.map(parse_thread_turns_cursor).transpose()?; - let page_size = limit - .map(|value| value as usize) - .unwrap_or(THREAD_TURNS_DEFAULT_LIMIT) - .clamp(1, THREAD_TURNS_MAX_LIMIT); - - let anchor_index = anchor - .as_ref() - .and_then(|anchor| turns.iter().position(|turn| turn.id == anchor.turn_id)); - if anchor.is_some() && anchor_index.is_none() { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "invalid cursor: anchor turn is no longer present".to_string(), - data: None, - }); - } - - let mut keyed_turns: Vec<_> = turns.into_iter().enumerate().collect(); - match sort_direction { - SortDirection::Asc => { - if let (Some(anchor), Some(anchor_index)) = (anchor.as_ref(), anchor_index) { - keyed_turns.retain(|(index, _)| { - if anchor.include_anchor { - *index >= anchor_index - } else { - *index > anchor_index - } - }); - } - } - SortDirection::Desc => { - keyed_turns.reverse(); - if let (Some(anchor), Some(anchor_index)) = (anchor.as_ref(), anchor_index) { - keyed_turns.retain(|(index, _)| { - if anchor.include_anchor { - *index <= anchor_index - } else { - *index < anchor_index - } - }); - } - } - } - - let more_turns_available = keyed_turns.len() > page_size; - keyed_turns.truncate(page_size); - let backwards_cursor = keyed_turns - .first() - .map(|(_, turn)| serialize_thread_turns_cursor(&turn.id, /*include_anchor*/ true)) - .transpose()?; - let next_cursor = if more_turns_available { - keyed_turns - .last() - .map(|(_, turn)| serialize_thread_turns_cursor(&turn.id, /*include_anchor*/ false)) - .transpose()? - } else { - None - }; - let turns = keyed_turns.into_iter().map(|(_, turn)| turn).collect(); - - Ok(ThreadTurnsPage { - turns, - next_cursor, - backwards_cursor, - }) -} - -fn serialize_thread_turns_cursor( - turn_id: &str, - include_anchor: bool, -) -> Result { - serde_json::to_string(&ThreadTurnsCursor { - turn_id: turn_id.to_string(), - include_anchor, - }) - .map_err(|err| JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to serialize cursor: {err}"), - data: None, - }) -} - -fn parse_thread_turns_cursor(cursor: &str) -> Result { - serde_json::from_str(cursor).map_err(|_| JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("invalid cursor: {cursor}"), - data: None, - }) -} - -fn reconstruct_thread_turns_from_rollout_items( - items: &[RolloutItem], - loaded_status: ThreadStatus, - has_live_in_progress_turn: bool, -) -> Vec { - let mut turns = build_turns_from_rollout_items(items); - normalize_thread_turns_status(&mut turns, loaded_status, has_live_in_progress_turn); - turns -} - -fn reconstruct_thread_turns_for_turns_list( - items: &[RolloutItem], - loaded_status: ThreadStatus, - has_live_running_thread: bool, - active_turn: Option, -) -> Vec { - let has_live_in_progress_turn = has_live_running_thread - || active_turn - .as_ref() - .is_some_and(|turn| matches!(turn.status, TurnStatus::InProgress)); - let mut turns = reconstruct_thread_turns_from_rollout_items( - items, - loaded_status, - has_live_in_progress_turn, - ); - if let Some(active_turn) = active_turn { - merge_turn_history_with_active_turn(&mut turns, active_turn); - } - turns -} - -fn normalize_thread_turns_status( - turns: &mut [Turn], - loaded_status: ThreadStatus, - has_live_in_progress_turn: bool, -) { - let status = resolve_thread_status(loaded_status, has_live_in_progress_turn); - if matches!(status, ThreadStatus::Active { .. }) { - return; - } - for turn in turns { - if matches!(turn.status, TurnStatus::InProgress) { - turn.status = TurnStatus::Interrupted; - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::outgoing_message::OutgoingEnvelope; - use crate::outgoing_message::OutgoingMessage; - use anyhow::Result; - use chrono::DateTime; - use chrono::Utc; - use codex_app_server_protocol::ServerRequestPayload; - use codex_app_server_protocol::ToolRequestUserInputParams; - use codex_config::CloudRequirementsLoader; - use codex_config::LoaderOverrides; - use codex_config::SessionThreadConfig; - use codex_config::StaticThreadConfigLoader; - use codex_config::ThreadConfigSource; - use codex_model_provider_info::ModelProviderInfo; - use codex_model_provider_info::WireApi; - use codex_protocol::ThreadId; - use codex_protocol::openai_models::ReasoningEffort; - use codex_protocol::permissions::FileSystemAccessMode; - use codex_protocol::permissions::FileSystemPath; - use codex_protocol::permissions::FileSystemSandboxEntry; - use codex_protocol::permissions::NetworkSandboxPolicy; - use codex_protocol::protocol::AskForApproval; - use codex_protocol::protocol::SandboxPolicy; - use codex_protocol::protocol::SessionSource; - use codex_protocol::protocol::SubAgentSource; - use codex_thread_store::StoredThread; - use codex_utils_absolute_path::test_support::PathBufExt; - use codex_utils_absolute_path::test_support::test_path_buf; - use pretty_assertions::assert_eq; - use serde_json::json; - use std::collections::BTreeMap; - use std::path::PathBuf; - use std::sync::Arc; - use tempfile::TempDir; - - #[test] - fn validate_dynamic_tools_rejects_unsupported_input_schema() { - let tools = vec![ApiDynamicToolSpec { - namespace: None, - name: "my_tool".to_string(), - description: "test".to_string(), - input_schema: json!({"type": "null"}), - defer_loading: false, - }]; - let err = validate_dynamic_tools(&tools).expect_err("invalid schema"); - assert!(err.contains("my_tool"), "unexpected error: {err}"); - } - - #[test] - fn validate_dynamic_tools_accepts_sanitizable_input_schema() { - let tools = vec![ApiDynamicToolSpec { - namespace: None, - name: "my_tool".to_string(), - description: "test".to_string(), - // Missing `type` is common; core sanitizes these to a supported schema. - input_schema: json!({"properties": {}}), - defer_loading: false, - }]; - validate_dynamic_tools(&tools).expect("valid schema"); - } - - #[test] - fn validate_dynamic_tools_accepts_nullable_field_schema() { - let tools = vec![ApiDynamicToolSpec { - namespace: None, - name: "my_tool".to_string(), - description: "test".to_string(), - input_schema: json!({ - "type": "object", - "properties": { - "query": {"type": ["string", "null"]} - }, - "required": ["query"], - "additionalProperties": false - }), - defer_loading: false, - }]; - validate_dynamic_tools(&tools).expect("valid schema"); - } - - #[test] - fn validate_dynamic_tools_accepts_same_name_in_different_namespaces() { - let tools = vec![ - ApiDynamicToolSpec { - namespace: Some("codex_app".to_string()), - name: "my_tool".to_string(), - description: "test".to_string(), - input_schema: json!({ - "type": "object", - "properties": {}, - "additionalProperties": false - }), - defer_loading: true, - }, - ApiDynamicToolSpec { - namespace: Some("other_app".to_string()), - name: "my_tool".to_string(), - description: "test".to_string(), - input_schema: json!({ - "type": "object", - "properties": {}, - "additionalProperties": false - }), - defer_loading: true, - }, - ]; - validate_dynamic_tools(&tools).expect("valid schema"); - } - - #[test] - fn validate_dynamic_tools_rejects_duplicate_name_in_same_namespace() { - let tools = vec![ - ApiDynamicToolSpec { - namespace: Some("codex_app".to_string()), - name: "my_tool".to_string(), - description: "test".to_string(), - input_schema: json!({ - "type": "object", - "properties": {}, - "additionalProperties": false - }), - defer_loading: true, - }, - ApiDynamicToolSpec { - namespace: Some("codex_app".to_string()), - name: "my_tool".to_string(), - description: "test".to_string(), - input_schema: json!({ - "type": "object", - "properties": {}, - "additionalProperties": false - }), - defer_loading: true, - }, - ]; - let err = validate_dynamic_tools(&tools).expect_err("duplicate name"); - assert!(err.contains("codex_app"), "unexpected error: {err}"); - assert!(err.contains("my_tool"), "unexpected error: {err}"); - } - - #[test] - fn thread_turns_list_merges_in_progress_active_turn_before_agent_status_running() { - let persisted_items = vec![RolloutItem::EventMsg(EventMsg::UserMessage( - codex_protocol::protocol::UserMessageEvent { - message: "persisted".to_string(), - images: None, - local_images: Vec::new(), - text_elements: Vec::new(), - }, - ))]; - let active_turn = Turn { - id: "live-turn".to_string(), - items: vec![ThreadItem::UserMessage { - id: "live-user-message".to_string(), - content: vec![V2UserInput::Text { - text: "live".to_string(), - text_elements: Vec::new(), - }], - }], - error: None, - status: TurnStatus::InProgress, - started_at: None, - completed_at: None, - duration_ms: None, - }; - - let turns = reconstruct_thread_turns_for_turns_list( - &persisted_items, - ThreadStatus::Idle, - /*has_live_running_thread*/ false, - Some(active_turn.clone()), - ); - - assert_eq!(turns.last(), Some(&active_turn)); - } - - #[test] - fn validate_dynamic_tools_rejects_empty_namespace() { - let tools = vec![ApiDynamicToolSpec { - namespace: Some("".to_string()), - name: "my_tool".to_string(), - description: "test".to_string(), - input_schema: json!({ - "type": "object", - "properties": {}, - "additionalProperties": false - }), - defer_loading: false, - }]; - let err = validate_dynamic_tools(&tools).expect_err("empty namespace"); - assert!(err.contains("my_tool"), "unexpected error: {err}"); - assert!(err.contains("namespace"), "unexpected error: {err}"); - } - - #[test] - fn validate_dynamic_tools_rejects_reserved_namespace() { - let tools = vec![ApiDynamicToolSpec { - namespace: Some("mcp__server__".to_string()), - name: "my_tool".to_string(), - description: "test".to_string(), - input_schema: json!({ - "type": "object", - "properties": {}, - "additionalProperties": false - }), - defer_loading: false, - }]; - let err = validate_dynamic_tools(&tools).expect_err("reserved namespace"); - assert!(err.contains("my_tool"), "unexpected error: {err}"); - assert!(err.contains("reserved"), "unexpected error: {err}"); - } - - #[test] - fn summary_from_stored_thread_preserves_millisecond_precision() { - let created_at = - DateTime::parse_from_rfc3339("2025-01-02T03:04:05.678Z").expect("valid timestamp"); - let updated_at = - DateTime::parse_from_rfc3339("2025-01-02T03:04:06.789Z").expect("valid timestamp"); - let thread_id = - ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread"); - let stored_thread = StoredThread { - thread_id, - rollout_path: Some(PathBuf::from("/tmp/thread.jsonl")), - forked_from_id: None, - preview: "preview".to_string(), - name: None, - model_provider: "openai".to_string(), - model: None, - reasoning_effort: None, - created_at: created_at.with_timezone(&Utc), - updated_at: updated_at.with_timezone(&Utc), - archived_at: None, - cwd: PathBuf::from("/tmp"), - cli_version: "0.0.0".to_string(), - source: SessionSource::Cli, - agent_nickname: None, - agent_role: None, - agent_path: None, - git_info: None, - approval_mode: AskForApproval::OnRequest, - sandbox_policy: SandboxPolicy::new_read_only_policy(), - token_usage: None, - first_user_message: Some("first user message".to_string()), - history: None, - }; - - let summary = - summary_from_stored_thread(stored_thread, "fallback").expect("summary should exist"); - - assert_eq!( - summary.timestamp.as_deref(), - Some("2025-01-02T03:04:05.678Z") - ); - assert_eq!( - summary.updated_at.as_deref(), - Some("2025-01-02T03:04:06.789Z") - ); - } - - #[test] - fn requested_permissions_trust_project_uses_permission_profile_intent() { - let cwd = test_path_buf("/tmp/project").abs(); - let full_access_profile = codex_protocol::models::PermissionProfile::Disabled; - let workspace_write_profile = codex_protocol::models::PermissionProfile::workspace_write(); - let read_only_profile = codex_protocol::models::PermissionProfile::read_only(); - let split_write_profile = - codex_protocol::models::PermissionProfile::from_runtime_permissions( - &FileSystemSandboxPolicy::restricted(vec![ - FileSystemSandboxEntry { - path: FileSystemPath::Path { path: cwd.clone() }, - access: FileSystemAccessMode::Write, - }, - FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { - pattern: "/tmp/project/**/*.env".to_string(), - }, - access: FileSystemAccessMode::None, - }, - ]), - NetworkSandboxPolicy::Restricted, - ); - - assert!(requested_permissions_trust_project( - &ConfigOverrides { - permission_profile: Some(full_access_profile), - ..Default::default() - }, - cwd.as_path() - )); - assert!(requested_permissions_trust_project( - &ConfigOverrides { - permission_profile: Some(workspace_write_profile), - ..Default::default() - }, - cwd.as_path() - )); - assert!(requested_permissions_trust_project( - &ConfigOverrides { - permission_profile: Some(split_write_profile), - ..Default::default() - }, - cwd.as_path() - )); - assert!(requested_permissions_trust_project( - &ConfigOverrides { - default_permissions: Some(":workspace".to_string()), - ..Default::default() - }, - cwd.as_path() - )); - assert!(requested_permissions_trust_project( - &ConfigOverrides { - default_permissions: Some(":danger-no-sandbox".to_string()), - ..Default::default() - }, - cwd.as_path() - )); - assert!(!requested_permissions_trust_project( - &ConfigOverrides { - permission_profile: Some(read_only_profile), - ..Default::default() - }, - cwd.as_path() - )); - assert!(!requested_permissions_trust_project( - &ConfigOverrides { - default_permissions: Some(":read-only".to_string()), - ..Default::default() - }, - cwd.as_path() - )); - } - - #[test] - fn command_profile_preserves_configured_deny_read_restrictions() { - let readable_entry = FileSystemSandboxEntry { - path: FileSystemPath::Path { - path: test_path_buf("/tmp/project").abs(), - }, - access: FileSystemAccessMode::Read, - }; - let deny_entry = FileSystemSandboxEntry { - path: FileSystemPath::GlobPattern { - pattern: "/tmp/project/**/*.env".to_string(), - }, - access: FileSystemAccessMode::None, - }; - let mut file_system_sandbox_policy = - FileSystemSandboxPolicy::restricted(vec![readable_entry.clone()]); - let mut configured_file_system_sandbox_policy = - FileSystemSandboxPolicy::restricted(vec![deny_entry.clone()]); - configured_file_system_sandbox_policy.glob_scan_max_depth = Some(2); - - CodexMessageProcessor::preserve_configured_deny_read_restrictions( - &mut file_system_sandbox_policy, - &configured_file_system_sandbox_policy, - ); - - let mut expected = FileSystemSandboxPolicy::restricted(vec![readable_entry, deny_entry]); - expected.glob_scan_max_depth = Some(2); - assert_eq!(file_system_sandbox_policy, expected); - } - - #[test] - fn config_load_error_marks_cloud_requirements_failures_for_relogin() { - let err = std::io::Error::other(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::Auth, - Some(401), - "Your authentication session could not be refreshed automatically. Please log out and sign in again.", - )); - - let error = config_load_error(&err); - - assert_eq!( - error.data, - Some(json!({ - "reason": "cloudRequirements", - "errorCode": "Auth", - "action": "relogin", - "statusCode": 401, - "detail": "Your authentication session could not be refreshed automatically. Please log out and sign in again.", - })) - ); - assert!( - error.message.contains("failed to load configuration"), - "unexpected error message: {}", - error.message - ); - } - - #[test] - fn config_load_error_leaves_non_cloud_requirements_failures_unmarked() { - let err = std::io::Error::other("required MCP servers failed to initialize"); - - let error = config_load_error(&err); - - assert_eq!(error.data, None); - assert!( - error.message.contains("failed to load configuration"), - "unexpected error message: {}", - error.message - ); - } - - #[test] - fn config_load_error_marks_non_auth_cloud_requirements_failures_without_relogin() { - let err = std::io::Error::other(CloudRequirementsLoadError::new( - CloudRequirementsLoadErrorCode::RequestFailed, - /*status_code*/ None, - "Failed to load cloud requirements (workspace-managed policies).", - )); - - let error = config_load_error(&err); - - assert_eq!( - error.data, - Some(json!({ - "reason": "cloudRequirements", - "errorCode": "RequestFailed", - "detail": "Failed to load cloud requirements (workspace-managed policies).", - })) - ); - } - - #[tokio::test] - async fn derive_config_from_params_uses_session_thread_config_model_provider() -> Result<()> { - let temp_dir = TempDir::new()?; - let session_provider = ModelProviderInfo { - name: "session".to_string(), - base_url: Some("http://127.0.0.1:8061/api/codex".to_string()), - env_key: None, - env_key_instructions: None, - experimental_bearer_token: None, - auth: None, - aws: None, - wire_api: WireApi::Responses, - query_params: None, - http_headers: None, - env_http_headers: None, - request_max_retries: None, - stream_max_retries: None, - stream_idle_timeout_ms: None, - websocket_connect_timeout_ms: None, - requires_openai_auth: false, - supports_websockets: true, - }; - let config_manager = ConfigManager::new( - temp_dir.path().to_path_buf(), - Vec::new(), - LoaderOverrides::default(), - CloudRequirementsLoader::default(), - Arg0DispatchPaths::default(), - Arc::new(StaticThreadConfigLoader::new(vec![ - ThreadConfigSource::Session(SessionThreadConfig { - model_provider: Some("session".to_string()), - model_providers: HashMap::from([( - "session".to_string(), - session_provider.clone(), - )]), - features: BTreeMap::from([("plugins".to_string(), false)]), - }), - ])), - ); - let config = config_manager - .load_with_overrides( - Some(HashMap::from([ - ("model_provider".to_string(), json!("request")), - ("features.plugins".to_string(), json!(true)), - ( - "model_providers.session".to_string(), - json!({ - "name": "request", - "base_url": "http://127.0.0.1:9999/api/codex", - "wire_api": "responses", - }), - ), - ])), - ConfigOverrides::default(), - ) - .await?; - - assert_eq!(config.model_provider_id, "session"); - assert_eq!(config.model_provider, session_provider); - assert!(!config.features.enabled(Feature::Plugins)); - Ok(()) - } - - #[test] - fn collect_resume_override_mismatches_includes_service_tier() { - let cwd = test_path_buf("/tmp").abs(); - let request = ThreadResumeParams { - thread_id: "thread-1".to_string(), - history: None, - path: None, - model: None, - model_provider: None, - service_tier: Some(Some(codex_protocol::config_types::ServiceTier::Fast)), - cwd: None, - approval_policy: None, - approvals_reviewer: None, - sandbox: None, - permissions: None, - config: None, - base_instructions: None, - developer_instructions: None, - personality: None, - exclude_turns: false, - persist_extended_history: false, - }; - let config_snapshot = ThreadConfigSnapshot { - model: "gpt-5".to_string(), - model_provider_id: "openai".to_string(), - service_tier: Some(codex_protocol::config_types::ServiceTier::Flex), - approval_policy: codex_protocol::protocol::AskForApproval::OnRequest, - approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, - permission_profile: codex_protocol::models::PermissionProfile::Disabled, - active_permission_profile: None, - cwd, - ephemeral: false, - reasoning_effort: None, - personality: None, - session_source: SessionSource::Cli, - }; - - assert_eq!( - collect_resume_override_mismatches(&request, &config_snapshot), - vec!["service_tier requested=Some(Fast) active=Some(Flex)".to_string()] - ); - } - - fn test_thread_metadata( - model: Option<&str>, - reasoning_effort: Option, - ) -> Result { - let thread_id = ThreadId::from_string("3f941c35-29b3-493b-b0a4-e25800d9aeb0")?; - let mut builder = ThreadMetadataBuilder::new( - thread_id, - PathBuf::from("/tmp/rollout.jsonl"), - Utc::now(), - codex_protocol::protocol::SessionSource::default(), - ); - builder.model_provider = Some("mock_provider".to_string()); - let mut metadata = builder.build("mock_provider"); - metadata.model = model.map(ToString::to_string); - metadata.reasoning_effort = reasoning_effort; - Ok(metadata) - } - - #[test] - fn summary_from_thread_metadata_formats_protocol_timestamps_as_seconds() -> Result<()> { - let mut metadata = - test_thread_metadata(/*model*/ None, /*reasoning_effort*/ None)?; - metadata.created_at = - DateTime::parse_from_rfc3339("2025-09-05T16:53:11.123Z")?.with_timezone(&Utc); - metadata.updated_at = - DateTime::parse_from_rfc3339("2025-09-05T16:53:12.456Z")?.with_timezone(&Utc); - - let summary = summary_from_thread_metadata(&metadata); - - assert_eq!(summary.timestamp, Some("2025-09-05T16:53:11Z".to_string())); - assert_eq!(summary.updated_at, Some("2025-09-05T16:53:12Z".to_string())); - Ok(()) - } - - #[test] - fn merge_persisted_resume_metadata_prefers_persisted_model_and_reasoning_effort() -> Result<()> - { - let mut request_overrides = None; - let mut typesafe_overrides = ConfigOverrides::default(); - let persisted_metadata = - test_thread_metadata(Some("gpt-5.1-codex-max"), Some(ReasoningEffort::High))?; - - merge_persisted_resume_metadata( - &mut request_overrides, - &mut typesafe_overrides, - &persisted_metadata, - ); - - assert_eq!( - typesafe_overrides.model, - Some("gpt-5.1-codex-max".to_string()) - ); - assert_eq!( - typesafe_overrides.model_provider, - Some("mock_provider".to_string()) - ); - assert_eq!( - request_overrides, - Some(HashMap::from([( - "model_reasoning_effort".to_string(), - serde_json::Value::String("high".to_string()), - )])) - ); - Ok(()) - } - - #[test] - fn merge_persisted_resume_metadata_preserves_explicit_overrides() -> Result<()> { - let mut request_overrides = Some(HashMap::from([( - "model_reasoning_effort".to_string(), - serde_json::Value::String("low".to_string()), - )])); - let mut typesafe_overrides = ConfigOverrides { - model: Some("gpt-5.2-codex".to_string()), - ..Default::default() - }; - let persisted_metadata = - test_thread_metadata(Some("gpt-5.1-codex-max"), Some(ReasoningEffort::High))?; - - merge_persisted_resume_metadata( - &mut request_overrides, - &mut typesafe_overrides, - &persisted_metadata, - ); - - assert_eq!(typesafe_overrides.model, Some("gpt-5.2-codex".to_string())); - assert_eq!(typesafe_overrides.model_provider, None); - assert_eq!( - request_overrides, - Some(HashMap::from([( - "model_reasoning_effort".to_string(), - serde_json::Value::String("low".to_string()), - )])) - ); - Ok(()) - } - - #[test] - fn merge_persisted_resume_metadata_skips_persisted_values_when_model_overridden() -> Result<()> - { - let mut request_overrides = Some(HashMap::from([( - "model".to_string(), - serde_json::Value::String("gpt-5.2-codex".to_string()), - )])); - let mut typesafe_overrides = ConfigOverrides::default(); - let persisted_metadata = - test_thread_metadata(Some("gpt-5.1-codex-max"), Some(ReasoningEffort::High))?; - - merge_persisted_resume_metadata( - &mut request_overrides, - &mut typesafe_overrides, - &persisted_metadata, - ); - - assert_eq!(typesafe_overrides.model, None); - assert_eq!(typesafe_overrides.model_provider, None); - assert_eq!( - request_overrides, - Some(HashMap::from([( - "model".to_string(), - serde_json::Value::String("gpt-5.2-codex".to_string()), - )])) - ); - Ok(()) - } - - #[test] - fn merge_persisted_resume_metadata_skips_persisted_values_when_provider_overridden() - -> Result<()> { - let mut request_overrides = None; - let mut typesafe_overrides = ConfigOverrides { - model_provider: Some("oss".to_string()), - ..Default::default() - }; - let persisted_metadata = - test_thread_metadata(Some("gpt-5.1-codex-max"), Some(ReasoningEffort::High))?; - - merge_persisted_resume_metadata( - &mut request_overrides, - &mut typesafe_overrides, - &persisted_metadata, - ); - - assert_eq!(typesafe_overrides.model, None); - assert_eq!(typesafe_overrides.model_provider, Some("oss".to_string())); - assert_eq!(request_overrides, None); - Ok(()) - } - - #[test] - fn merge_persisted_resume_metadata_skips_persisted_values_when_reasoning_effort_overridden() - -> Result<()> { - let mut request_overrides = Some(HashMap::from([( - "model_reasoning_effort".to_string(), - serde_json::Value::String("low".to_string()), - )])); - let mut typesafe_overrides = ConfigOverrides::default(); - let persisted_metadata = - test_thread_metadata(Some("gpt-5.1-codex-max"), Some(ReasoningEffort::High))?; - - merge_persisted_resume_metadata( - &mut request_overrides, - &mut typesafe_overrides, - &persisted_metadata, - ); - - assert_eq!(typesafe_overrides.model, None); - assert_eq!(typesafe_overrides.model_provider, None); - assert_eq!( - request_overrides, - Some(HashMap::from([( - "model_reasoning_effort".to_string(), - serde_json::Value::String("low".to_string()), - )])) - ); - Ok(()) - } - - #[test] - fn merge_persisted_resume_metadata_skips_missing_values() -> Result<()> { - let mut request_overrides = None; - let mut typesafe_overrides = ConfigOverrides::default(); - let persisted_metadata = - test_thread_metadata(/*model*/ None, /*reasoning_effort*/ None)?; - - merge_persisted_resume_metadata( - &mut request_overrides, - &mut typesafe_overrides, - &persisted_metadata, - ); - - assert_eq!(typesafe_overrides.model, None); - assert_eq!( - typesafe_overrides.model_provider, - Some("mock_provider".to_string()) - ); - assert_eq!(request_overrides, None); - Ok(()) - } - - #[test] - fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> { - let conversation_id = ThreadId::from_string("3f941c35-29b3-493b-b0a4-e25800d9aeb0")?; - let timestamp = Some("2025-09-05T16:53:11.850Z".to_string()); - let path = PathBuf::from("rollout.jsonl"); - - let head = vec![ - json!({ - "id": conversation_id.to_string(), - "timestamp": timestamp, - "cwd": "/", - "originator": "codex", - "cli_version": "0.0.0", - "model_provider": "test-provider" - }), - json!({ - "type": "message", - "role": "user", - "content": [{ - "type": "input_text", - "text": "# AGENTS.md instructions for project\n\n\n\n".to_string(), - }], - }), - json!({ - "type": "message", - "role": "user", - "content": [{ - "type": "input_text", - "text": format!(" {USER_MESSAGE_BEGIN}Count to 5"), - }], - }), - ]; - - let session_meta = serde_json::from_value::(head[0].clone())?; - - let summary = extract_conversation_summary( - path.clone(), - &head, - &session_meta, - /*git*/ None, - "test-provider", - timestamp.clone(), - ) - .expect("summary"); - - let expected = ConversationSummary { - conversation_id, - timestamp: timestamp.clone(), - updated_at: timestamp, - path, - preview: "Count to 5".to_string(), - model_provider: "test-provider".to_string(), - cwd: PathBuf::from("/"), - cli_version: "0.0.0".to_string(), - source: SessionSource::VSCode, - git_info: None, - }; - - assert_eq!(summary, expected); - Ok(()) - } - - #[tokio::test] - async fn read_summary_from_rollout_returns_empty_preview_when_no_user_message() -> Result<()> { - use codex_protocol::protocol::RolloutItem; - use codex_protocol::protocol::RolloutLine; - use codex_protocol::protocol::SessionMetaLine; - use std::fs; - use std::fs::FileTimes; - - let temp_dir = TempDir::new()?; - let path = temp_dir.path().join("rollout.jsonl"); - - let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; - let timestamp = "2025-09-05T16:53:11.850Z".to_string(); - - let session_meta = SessionMeta { - id: conversation_id, - timestamp: timestamp.clone(), - model_provider: None, - ..SessionMeta::default() - }; - - let line = RolloutLine { - timestamp: timestamp.clone(), - item: RolloutItem::SessionMeta(SessionMetaLine { - meta: session_meta.clone(), - git: None, - }), - }; - - fs::write(&path, format!("{}\n", serde_json::to_string(&line)?))?; - let parsed = chrono::DateTime::parse_from_rfc3339(×tamp)?.with_timezone(&Utc); - let times = FileTimes::new().set_modified(parsed.into()); - std::fs::OpenOptions::new() - .append(true) - .open(&path)? - .set_times(times)?; - - let summary = read_summary_from_rollout(path.as_path(), "fallback").await?; - - let expected = ConversationSummary { - conversation_id, - timestamp: Some(timestamp.clone()), - updated_at: Some(timestamp), - path: path.clone(), - preview: String::new(), - model_provider: "fallback".to_string(), - cwd: PathBuf::new(), - cli_version: String::new(), - source: SessionSource::VSCode, - git_info: None, - }; - - assert_eq!(summary, expected); - Ok(()) - } - - #[tokio::test] - async fn read_summary_from_rollout_preserves_agent_nickname() -> Result<()> { - use codex_protocol::protocol::RolloutItem; - use codex_protocol::protocol::RolloutLine; - use codex_protocol::protocol::SessionMetaLine; - use std::fs; - - let temp_dir = TempDir::new()?; - let path = temp_dir.path().join("rollout.jsonl"); - - let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; - let parent_thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; - let timestamp = "2025-09-05T16:53:11.850Z".to_string(); - - let session_meta = SessionMeta { - id: conversation_id, - timestamp: timestamp.clone(), - source: SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id, - depth: 1, - agent_path: None, - agent_nickname: None, - agent_role: None, - }), - agent_nickname: Some("atlas".to_string()), - agent_role: Some("explorer".to_string()), - model_provider: Some("test-provider".to_string()), - ..SessionMeta::default() - }; - - let line = RolloutLine { - timestamp, - item: RolloutItem::SessionMeta(SessionMetaLine { - meta: session_meta, - git: None, - }), - }; - fs::write(&path, format!("{}\n", serde_json::to_string(&line)?))?; - - let summary = read_summary_from_rollout(path.as_path(), "fallback").await?; - let fallback_cwd = AbsolutePathBuf::from_absolute_path("/")?; - let thread = summary_to_thread(summary, &fallback_cwd); - - assert_eq!(thread.agent_nickname, Some("atlas".to_string())); - assert_eq!(thread.agent_role, Some("explorer".to_string())); - Ok(()) - } - - #[tokio::test] - async fn read_summary_from_rollout_preserves_forked_from_id() -> Result<()> { - use codex_protocol::protocol::RolloutItem; - use codex_protocol::protocol::RolloutLine; - use codex_protocol::protocol::SessionMetaLine; - use std::fs; - - let temp_dir = TempDir::new()?; - let path = temp_dir.path().join("rollout.jsonl"); - - let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; - let forked_from_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; - let timestamp = "2025-09-05T16:53:11.850Z".to_string(); - - let session_meta = SessionMeta { - id: conversation_id, - forked_from_id: Some(forked_from_id), - timestamp: timestamp.clone(), - model_provider: Some("test-provider".to_string()), - ..SessionMeta::default() - }; - - let line = RolloutLine { - timestamp, - item: RolloutItem::SessionMeta(SessionMetaLine { - meta: session_meta, - git: None, - }), - }; - fs::write(&path, format!("{}\n", serde_json::to_string(&line)?))?; - - assert_eq!( - forked_from_id_from_rollout(path.as_path()).await, - Some(forked_from_id.to_string()) - ); - Ok(()) - } - - #[tokio::test] - async fn aborting_pending_request_clears_pending_state() -> Result<()> { - let thread_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; - let connection_id = ConnectionId(7); - - let (outgoing_tx, mut outgoing_rx) = tokio::sync::mpsc::channel(8); - let outgoing = Arc::new(OutgoingMessageSender::new( - outgoing_tx, - codex_analytics::AnalyticsEventsClient::disabled(), - )); - let thread_outgoing = ThreadScopedOutgoingMessageSender::new( - outgoing.clone(), - vec![connection_id], - thread_id, - ); - - let (request_id, client_request_rx) = thread_outgoing - .send_request(ServerRequestPayload::ToolRequestUserInput( - ToolRequestUserInputParams { - thread_id: thread_id.to_string(), - turn_id: "turn-1".to_string(), - item_id: "call-1".to_string(), - questions: vec![], - }, - )) - .await; - thread_outgoing.abort_pending_server_requests().await; - - let request_message = outgoing_rx.recv().await.expect("request should be sent"); - let OutgoingEnvelope::ToConnection { - connection_id: request_connection_id, - message: - OutgoingMessage::Request(ServerRequest::ToolRequestUserInput { - request_id: sent_request_id, - .. - }), - .. - } = request_message - else { - panic!("expected tool request to be sent to the subscribed connection"); - }; - assert_eq!(request_connection_id, connection_id); - assert_eq!(sent_request_id, request_id); - - let response = client_request_rx - .await - .expect("callback should be resolved"); - let error = response.expect_err("request should be aborted during cleanup"); - assert_eq!( - error.message, - "client request resolved because the turn state was changed" - ); - assert_eq!(error.data, Some(json!({ "reason": "turnTransition" }))); - assert!( - outgoing - .pending_requests_for_thread(thread_id) - .await - .is_empty() - ); - assert!(outgoing_rx.try_recv().is_err()); - Ok(()) - } - - #[test] - fn summary_from_state_db_metadata_preserves_agent_nickname() -> Result<()> { - let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; - let source = - serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::ThreadSpawn { - parent_thread_id: ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?, - depth: 1, - agent_path: None, - agent_nickname: None, - agent_role: None, - }))?; - - let summary = summary_from_state_db_metadata( - conversation_id, - PathBuf::from("/tmp/rollout.jsonl"), - Some("hi".to_string()), - "2025-09-05T16:53:11Z".to_string(), - "2025-09-05T16:53:12Z".to_string(), - "test-provider".to_string(), - PathBuf::from("/"), - "0.0.0".to_string(), - source, - Some("atlas".to_string()), - Some("explorer".to_string()), - /*git_sha*/ None, - /*git_branch*/ None, - /*git_origin_url*/ None, - ); - - let fallback_cwd = AbsolutePathBuf::from_absolute_path("/")?; - let thread = summary_to_thread(summary, &fallback_cwd); - - assert_eq!(thread.agent_nickname, Some("atlas".to_string())); - assert_eq!(thread.agent_role, Some("explorer".to_string())); - Ok(()) - } - - #[tokio::test] - async fn removing_thread_state_clears_listener_and_active_turn_history() -> Result<()> { - let manager = ThreadStateManager::new(); - let thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; - let connection = ConnectionId(1); - let (cancel_tx, cancel_rx) = oneshot::channel(); - - manager.connection_initialized(connection).await; - manager - .try_ensure_connection_subscribed( - thread_id, connection, /*experimental_raw_events*/ false, - ) - .await - .expect("connection should be live"); - { - let state = manager.thread_state(thread_id).await; - let mut state = state.lock().await; - state.cancel_tx = Some(cancel_tx); - state.track_current_turn_event( - "turn-1", - &EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { - turn_id: "turn-1".to_string(), - started_at: None, - model_context_window: None, - collaboration_mode_kind: Default::default(), - }), - ); - } - - manager.remove_thread_state(thread_id).await; - assert_eq!(cancel_rx.await, Ok(())); - - let state = manager.thread_state(thread_id).await; - let subscribed_connection_ids = manager.subscribed_connection_ids(thread_id).await; - assert!(subscribed_connection_ids.is_empty()); - let state = state.lock().await; - assert!(state.cancel_tx.is_none()); - assert!(state.active_turn_snapshot().is_none()); - Ok(()) - } - - #[tokio::test] - async fn removing_auto_attached_connection_preserves_listener_for_other_connections() - -> Result<()> { - let manager = ThreadStateManager::new(); - let thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; - let connection_a = ConnectionId(1); - let connection_b = ConnectionId(2); - let (cancel_tx, mut cancel_rx) = oneshot::channel(); - - manager.connection_initialized(connection_a).await; - manager.connection_initialized(connection_b).await; - manager - .try_ensure_connection_subscribed( - thread_id, - connection_a, - /*experimental_raw_events*/ false, - ) - .await - .expect("connection_a should be live"); - manager - .try_ensure_connection_subscribed( - thread_id, - connection_b, - /*experimental_raw_events*/ false, - ) - .await - .expect("connection_b should be live"); - { - let state = manager.thread_state(thread_id).await; - state.lock().await.cancel_tx = Some(cancel_tx); - } - - let threads_to_unload = manager.remove_connection(connection_a).await; - assert_eq!(threads_to_unload, Vec::::new()); - assert!( - tokio::time::timeout(Duration::from_millis(20), &mut cancel_rx) - .await - .is_err() - ); - - assert_eq!( - manager.subscribed_connection_ids(thread_id).await, - vec![connection_b] - ); - Ok(()) - } - - #[tokio::test] - async fn adding_connection_to_thread_updates_has_connections_watcher() -> Result<()> { - let manager = ThreadStateManager::new(); - let thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; - let connection_a = ConnectionId(1); - let connection_b = ConnectionId(2); - - manager.connection_initialized(connection_a).await; - manager.connection_initialized(connection_b).await; - manager - .try_ensure_connection_subscribed( - thread_id, - connection_a, - /*experimental_raw_events*/ false, - ) - .await - .expect("connection_a should be live"); - let mut has_connections = manager - .subscribe_to_has_connections(thread_id) - .await - .expect("thread should have a has-connections watcher"); - assert!(*has_connections.borrow()); - - assert!( - manager - .unsubscribe_connection_from_thread(thread_id, connection_a) - .await - ); - tokio::time::timeout(Duration::from_secs(1), has_connections.changed()) - .await - .expect("timed out waiting for no-subscriber update") - .expect("has-connections watcher should remain open"); - assert!(!*has_connections.borrow()); - - assert!( - manager - .try_add_connection_to_thread(thread_id, connection_b) - .await - ); - tokio::time::timeout(Duration::from_secs(1), has_connections.changed()) - .await - .expect("timed out waiting for subscriber update") - .expect("has-connections watcher should remain open"); - assert!(*has_connections.borrow()); - Ok(()) - } - - #[tokio::test] - async fn closed_connection_cannot_be_reintroduced_by_auto_subscribe() -> Result<()> { - let manager = ThreadStateManager::new(); - let thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; - let connection = ConnectionId(1); - - manager.connection_initialized(connection).await; - let threads_to_unload = manager.remove_connection(connection).await; - assert_eq!(threads_to_unload, Vec::::new()); - - assert!( - manager - .try_ensure_connection_subscribed( - thread_id, connection, /*experimental_raw_events*/ false - ) - .await - .is_none() - ); - assert!(!manager.has_subscribers(thread_id).await); - Ok(()) - } -} diff --git a/codex-rs/app-server/src/codex_message_processor/apps_list_helpers.rs b/codex-rs/app-server/src/codex_message_processor/apps_list_helpers.rs deleted file mode 100644 index b0a6df4a80..0000000000 --- a/codex-rs/app-server/src/codex_message_processor/apps_list_helpers.rs +++ /dev/null @@ -1,66 +0,0 @@ -use std::sync::Arc; - -use codex_app_server_protocol::AppInfo; -use codex_app_server_protocol::AppListUpdatedNotification; -use codex_app_server_protocol::AppsListResponse; -use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::ServerNotification; -use codex_chatgpt::connectors; - -use crate::error_code::INVALID_REQUEST_ERROR_CODE; -use crate::outgoing_message::OutgoingMessageSender; - -pub(super) fn merge_loaded_apps( - all_connectors: Option<&[AppInfo]>, - accessible_connectors: Option<&[AppInfo]>, -) -> Vec { - let all_connectors_loaded = all_connectors.is_some(); - let all = all_connectors.map_or_else(Vec::new, <[AppInfo]>::to_vec); - let accessible = accessible_connectors.map_or_else(Vec::new, <[AppInfo]>::to_vec); - connectors::merge_connectors_with_accessible(all, accessible, all_connectors_loaded) -} - -pub(super) fn should_send_app_list_updated_notification( - connectors: &[AppInfo], - accessible_loaded: bool, - all_loaded: bool, -) -> bool { - connectors.iter().any(|connector| connector.is_accessible) || (accessible_loaded && all_loaded) -} - -pub(super) fn paginate_apps( - connectors: &[AppInfo], - start: usize, - limit: Option, -) -> Result { - let total = connectors.len(); - if start > total { - return Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("cursor {start} exceeds total apps {total}"), - data: None, - }); - } - - let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; - let end = start.saturating_add(effective_limit).min(total); - let data = connectors[start..end].to_vec(); - let next_cursor = if end < total { - Some(end.to_string()) - } else { - None - }; - - Ok(AppsListResponse { data, next_cursor }) -} - -pub(super) async fn send_app_list_updated_notification( - outgoing: &Arc, - data: Vec, -) { - outgoing - .send_server_notification(ServerNotification::AppListUpdated( - AppListUpdatedNotification { data }, - )) - .await; -} diff --git a/codex-rs/app-server/src/codex_message_processor/plugin_app_helpers.rs b/codex-rs/app-server/src/codex_message_processor/plugin_app_helpers.rs deleted file mode 100644 index 7a409d4ce4..0000000000 --- a/codex-rs/app-server/src/codex_message_processor/plugin_app_helpers.rs +++ /dev/null @@ -1,149 +0,0 @@ -use std::collections::HashSet; - -use codex_app_server_protocol::AppInfo; -use codex_app_server_protocol::AppSummary; -use codex_chatgpt::connectors; -use codex_core::config::Config; -use codex_exec_server::EnvironmentManager; -use codex_plugin::AppConnectorId; -use tracing::warn; - -pub(super) async fn load_plugin_app_summaries( - config: &Config, - plugin_apps: &[AppConnectorId], - environment_manager: &EnvironmentManager, -) -> Vec { - if plugin_apps.is_empty() { - return Vec::new(); - } - - let connectors = - match connectors::list_all_connectors_with_options(config, /*force_refetch*/ false).await { - Ok(connectors) => connectors, - Err(err) => { - warn!("failed to load app metadata for plugin/read: {err:#}"); - connectors::list_cached_all_connectors(config) - .await - .unwrap_or_default() - } - }; - - let plugin_connectors = connectors::connectors_for_plugin_apps(connectors, plugin_apps); - - let accessible_connectors = - match connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( - config, - /*force_refetch*/ false, - environment_manager, - ) - .await - { - Ok(status) if status.codex_apps_ready => status.connectors, - Ok(_) => { - return plugin_connectors - .into_iter() - .map(AppSummary::from) - .collect(); - } - Err(err) => { - warn!("failed to load app auth state for plugin/read: {err:#}"); - return plugin_connectors - .into_iter() - .map(AppSummary::from) - .collect(); - } - }; - - let accessible_ids = accessible_connectors - .iter() - .map(|connector| connector.id.as_str()) - .collect::>(); - - plugin_connectors - .into_iter() - .map(|connector| { - let needs_auth = !accessible_ids.contains(connector.id.as_str()); - AppSummary { - id: connector.id, - name: connector.name, - description: connector.description, - install_url: connector.install_url, - needs_auth, - } - }) - .collect() -} - -pub(super) fn plugin_apps_needing_auth( - all_connectors: &[AppInfo], - accessible_connectors: &[AppInfo], - plugin_apps: &[AppConnectorId], - codex_apps_ready: bool, -) -> Vec { - if !codex_apps_ready { - return Vec::new(); - } - - let accessible_ids = accessible_connectors - .iter() - .map(|connector| connector.id.as_str()) - .collect::>(); - let plugin_app_ids = plugin_apps - .iter() - .map(|connector_id| connector_id.0.as_str()) - .collect::>(); - - all_connectors - .iter() - .filter(|connector| { - plugin_app_ids.contains(connector.id.as_str()) - && !accessible_ids.contains(connector.id.as_str()) - }) - .cloned() - .map(|connector| AppSummary { - id: connector.id, - name: connector.name, - description: connector.description, - install_url: connector.install_url, - needs_auth: true, - }) - .collect() -} - -#[cfg(test)] -mod tests { - use codex_app_server_protocol::AppInfo; - use codex_plugin::AppConnectorId; - use pretty_assertions::assert_eq; - - use super::plugin_apps_needing_auth; - - #[test] - fn plugin_apps_needing_auth_returns_empty_when_codex_apps_is_not_ready() { - let all_connectors = vec![AppInfo { - id: "alpha".to_string(), - name: "Alpha".to_string(), - description: Some("Alpha connector".to_string()), - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some("https://chatgpt.com/apps/alpha/alpha".to_string()), - is_accessible: false, - is_enabled: true, - plugin_display_names: Vec::new(), - }]; - - assert_eq!( - plugin_apps_needing_auth( - &all_connectors, - &[], - &[AppConnectorId("alpha".to_string())], - /*codex_apps_ready*/ false, - ), - Vec::new() - ); - } -} diff --git a/codex-rs/app-server/src/codex_message_processor/plugin_mcp_oauth.rs b/codex-rs/app-server/src/codex_message_processor/plugin_mcp_oauth.rs deleted file mode 100644 index b027aef453..0000000000 --- a/codex-rs/app-server/src/codex_message_processor/plugin_mcp_oauth.rs +++ /dev/null @@ -1,95 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use codex_app_server_protocol::McpServerOauthLoginCompletedNotification; -use codex_app_server_protocol::ServerNotification; -use codex_config::types::McpServerConfig; -use codex_core::config::Config; -use codex_mcp::McpOAuthLoginSupport; -use codex_mcp::oauth_login_support; -use codex_mcp::resolve_oauth_scopes; -use codex_mcp::should_retry_without_scopes; -use codex_rmcp_client::perform_oauth_login_silent; -use tracing::warn; - -use super::CodexMessageProcessor; - -impl CodexMessageProcessor { - pub(super) async fn start_plugin_mcp_oauth_logins( - &self, - config: &Config, - plugin_mcp_servers: HashMap, - ) { - for (name, server) in plugin_mcp_servers { - let oauth_config = match oauth_login_support(&server.transport).await { - McpOAuthLoginSupport::Supported(config) => config, - McpOAuthLoginSupport::Unsupported => continue, - McpOAuthLoginSupport::Unknown(err) => { - warn!( - "MCP server may or may not require login for plugin install {name}: {err}" - ); - continue; - } - }; - - let resolved_scopes = resolve_oauth_scopes( - /*explicit_scopes*/ None, - server.scopes.clone(), - oauth_config.discovered_scopes.clone(), - ); - - let store_mode = config.mcp_oauth_credentials_store_mode; - let callback_port = config.mcp_oauth_callback_port; - let callback_url = config.mcp_oauth_callback_url.clone(); - let outgoing = Arc::clone(&self.outgoing); - let notification_name = name.clone(); - - tokio::spawn(async move { - let first_attempt = perform_oauth_login_silent( - &name, - &oauth_config.url, - store_mode, - oauth_config.http_headers.clone(), - oauth_config.env_http_headers.clone(), - &resolved_scopes.scopes, - server.oauth_resource.as_deref(), - callback_port, - callback_url.as_deref(), - ) - .await; - - let final_result = match first_attempt { - Err(err) if should_retry_without_scopes(&resolved_scopes, &err) => { - perform_oauth_login_silent( - &name, - &oauth_config.url, - store_mode, - oauth_config.http_headers, - oauth_config.env_http_headers, - &[], - server.oauth_resource.as_deref(), - callback_port, - callback_url.as_deref(), - ) - .await - } - result => result, - }; - - let (success, error) = match final_result { - Ok(()) => (true, None), - Err(err) => (false, Some(err.to_string())), - }; - - let notification = ServerNotification::McpServerOauthLoginCompleted( - McpServerOauthLoginCompletedNotification { - name: notification_name, - success, - error, - }, - ); - outgoing.send_server_notification(notification).await; - }); - } - } -} diff --git a/codex-rs/app-server/src/codex_message_processor/plugins.rs b/codex-rs/app-server/src/codex_message_processor/plugins.rs deleted file mode 100644 index 5bab115517..0000000000 --- a/codex-rs/app-server/src/codex_message_processor/plugins.rs +++ /dev/null @@ -1,1025 +0,0 @@ -use super::*; -use crate::error_code::internal_error; -use crate::error_code::invalid_request; -use codex_app_server_protocol::PluginAvailability; -use codex_app_server_protocol::PluginInstallPolicy; -use codex_core_plugins::remote::is_valid_remote_plugin_id; -use codex_core_plugins::remote::validate_remote_plugin_id; - -impl CodexMessageProcessor { - pub(super) async fn plugin_list( - &self, - request_id: ConnectionRequestId, - params: PluginListParams, - ) { - let result = self.plugin_list_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn plugin_list_response( - &self, - params: PluginListParams, - ) -> Result { - let plugins_manager = self.thread_manager.plugins_manager(); - let PluginListParams { cwds } = params; - let roots = cwds.unwrap_or_default(); - - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; - let empty_response = || PluginListResponse { - marketplaces: Vec::new(), - marketplace_load_errors: Vec::new(), - featured_plugin_ids: Vec::new(), - }; - if !config.features.enabled(Feature::Plugins) { - return Ok(empty_response()); - } - let auth = self.auth_manager.auth().await; - if !self - .workspace_codex_plugins_enabled(&config, auth.as_ref()) - .await - { - return Ok(empty_response()); - } - let plugins_input = config.plugins_config_input(); - plugins_manager.maybe_start_plugin_list_background_tasks_for_config( - &plugins_input, - auth.clone(), - &roots, - Some(self.effective_plugins_changed_callback(config.clone())), - ); - - let config_for_marketplace_listing = plugins_input.clone(); - let plugins_manager_for_marketplace_listing = plugins_manager.clone(); - let (mut data, marketplace_load_errors) = match tokio::task::spawn_blocking(move || { - let outcome = plugins_manager_for_marketplace_listing - .list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?; - Ok::< - ( - Vec, - Vec, - ), - MarketplaceError, - >(( - outcome - .marketplaces - .into_iter() - .map(|marketplace| PluginMarketplaceEntry { - name: marketplace.name, - path: Some(marketplace.path), - interface: marketplace.interface.map(|interface| MarketplaceInterface { - display_name: interface.display_name, - }), - plugins: marketplace - .plugins - .into_iter() - .map(|plugin| PluginSummary { - id: plugin.id, - installed: plugin.installed, - enabled: plugin.enabled, - name: plugin.name, - source: marketplace_plugin_source_to_info(plugin.source), - install_policy: plugin.policy.installation.into(), - auth_policy: plugin.policy.authentication.into(), - availability: PluginAvailability::Available, - interface: plugin.interface.map(local_plugin_interface_to_info), - }) - .collect(), - }) - .collect(), - outcome - .errors - .into_iter() - .map(|err| codex_app_server_protocol::MarketplaceLoadErrorInfo { - marketplace_path: err.path, - message: err.message, - }) - .collect(), - )) - }) - .await - { - Ok(Ok(outcome)) => outcome, - Ok(Err(err)) => return Err(Self::marketplace_error(err, "list marketplace plugins")), - Err(err) => { - return Err(internal_error(format!( - "failed to list marketplace plugins: {err}" - ))); - } - }; - - if config.features.enabled(Feature::RemotePlugin) { - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; - match codex_core_plugins::remote::fetch_remote_marketplaces( - &remote_plugin_service_config, - auth.as_ref(), - ) - .await - { - Ok(remote_marketplaces) => { - for remote_marketplace in remote_marketplaces - .into_iter() - .map(remote_marketplace_to_info) - { - if let Some(existing) = data - .iter_mut() - .find(|marketplace| marketplace.name == remote_marketplace.name) - { - *existing = remote_marketplace; - } else { - data.push(remote_marketplace); - } - } - } - Err( - RemotePluginCatalogError::AuthRequired - | RemotePluginCatalogError::UnsupportedAuthMode, - ) => {} - Err(err) => { - warn!( - error = %err, - "plugin/list remote plugin catalog fetch failed; returning local marketplaces only" - ); - } - } - } - - let featured_plugin_ids = if data - .iter() - .any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) - { - match plugins_manager - .featured_plugin_ids_for_config(&plugins_input, auth.as_ref()) - .await - { - Ok(featured_plugin_ids) => featured_plugin_ids, - Err(err) => { - warn!( - error = %err, - "plugin/list featured plugin fetch failed; returning empty featured ids" - ); - Vec::new() - } - } - } else { - Vec::new() - }; - - Ok(PluginListResponse { - marketplaces: data, - marketplace_load_errors, - featured_plugin_ids, - }) - } - - pub(super) async fn plugin_read( - &self, - request_id: ConnectionRequestId, - params: PluginReadParams, - ) { - let result = self.plugin_read_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn plugin_read_response( - &self, - params: PluginReadParams, - ) -> Result { - let plugins_manager = self.thread_manager.plugins_manager(); - let PluginReadParams { - marketplace_path, - remote_marketplace_name, - plugin_name, - } = params; - let read_source = match (marketplace_path, remote_marketplace_name) { - (Some(marketplace_path), None) => Ok(marketplace_path), - (None, Some(remote_marketplace_name)) => Err(remote_marketplace_name), - (Some(_), Some(_)) | (None, None) => { - return Err(invalid_request( - "plugin/read requires exactly one of marketplacePath or remoteMarketplaceName", - )); - } - }; - let config_cwd = read_source.as_ref().ok().and_then(|marketplace_path| { - marketplace_path.as_path().parent().map(Path::to_path_buf) - }); - - let config = self.load_latest_config(config_cwd).await?; - let plugins_input = config.plugins_config_input(); - - let plugin = match read_source { - Ok(marketplace_path) => { - let request = PluginReadRequest { - plugin_name, - marketplace_path, - }; - let outcome = plugins_manager - .read_plugin_for_config(&plugins_input, &request) - .await - .map_err(|err| Self::marketplace_error(err, "read plugin details"))?; - let environment_manager = self.thread_manager.environment_manager(); - let app_summaries = plugin_app_helpers::load_plugin_app_summaries( - &config, - &outcome.plugin.apps, - &environment_manager, - ) - .await; - let visible_skills = outcome - .plugin - .skills - .iter() - .filter(|skill| { - skill.matches_product_restriction_for_product( - self.thread_manager.session_source().restriction_product(), - ) - }) - .cloned() - .collect::>(); - PluginDetail { - marketplace_name: outcome.marketplace_name, - marketplace_path: outcome.marketplace_path, - summary: PluginSummary { - id: outcome.plugin.id, - name: outcome.plugin.name, - source: marketplace_plugin_source_to_info(outcome.plugin.source), - installed: outcome.plugin.installed, - enabled: outcome.plugin.enabled, - install_policy: outcome.plugin.policy.installation.into(), - auth_policy: outcome.plugin.policy.authentication.into(), - availability: PluginAvailability::Available, - interface: outcome.plugin.interface.map(local_plugin_interface_to_info), - }, - description: outcome.plugin.description, - skills: plugin_skills_to_info( - &visible_skills, - &outcome.plugin.disabled_skill_paths, - ), - apps: app_summaries, - mcp_servers: outcome.plugin.mcp_server_names, - } - } - Err(remote_marketplace_name) => { - if !config.features.enabled(Feature::Plugins) - || !config.features.enabled(Feature::RemotePlugin) - { - return Err(invalid_request(format!( - "remote plugin read is not enabled for marketplace {remote_marketplace_name}" - ))); - } - let auth = self.auth_manager.auth().await; - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; - validate_remote_plugin_id(&plugin_name)?; - let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail( - &remote_plugin_service_config, - auth.as_ref(), - &remote_marketplace_name, - &plugin_name, - ) - .await - .map_err(|err| { - remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin details") - })?; - let plugin_apps = remote_detail - .app_ids - .iter() - .cloned() - .map(codex_plugin::AppConnectorId) - .collect::>(); - let environment_manager = self.thread_manager.environment_manager(); - let app_summaries = plugin_app_helpers::load_plugin_app_summaries( - &config, - &plugin_apps, - &environment_manager, - ) - .await; - remote_plugin_detail_to_info(remote_detail, app_summaries) - } - }; - - Ok(PluginReadResponse { plugin }) - } - - pub(super) async fn plugin_skill_read( - &self, - request_id: ConnectionRequestId, - params: PluginSkillReadParams, - ) { - let result = self.plugin_skill_read_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn plugin_skill_read_response( - &self, - params: PluginSkillReadParams, - ) -> Result { - let PluginSkillReadParams { - remote_marketplace_name, - remote_plugin_id, - skill_name, - } = params; - - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; - if !config.features.enabled(Feature::Plugins) - || !config.features.enabled(Feature::RemotePlugin) - { - return Err(invalid_request(format!( - "remote plugin skill read is not enabled for marketplace {remote_marketplace_name}" - ))); - } - validate_remote_plugin_id(&remote_plugin_id)?; - if skill_name.is_empty() { - return Err(invalid_request( - "invalid remote plugin skill name: cannot be empty", - )); - } - - let auth = self.auth_manager.auth().await; - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; - let remote_skill_detail = codex_core_plugins::remote::fetch_remote_plugin_skill_detail( - &remote_plugin_service_config, - auth.as_ref(), - &remote_marketplace_name, - &remote_plugin_id, - &skill_name, - ) - .await - .map_err(|err| { - remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin skill details") - })?; - - Ok(PluginSkillReadResponse { - contents: remote_skill_detail.contents, - }) - } - - pub(super) async fn plugin_share_save( - &self, - request_id: ConnectionRequestId, - params: PluginShareSaveParams, - ) { - let result = self.plugin_share_save_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn plugin_share_save_response( - &self, - params: PluginShareSaveParams, - ) -> Result { - let (config, auth) = self.load_plugin_share_config_and_auth().await?; - let PluginShareSaveParams { - plugin_path, - remote_plugin_id, - } = params; - if let Some(remote_plugin_id) = remote_plugin_id.as_ref() - && (remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(remote_plugin_id)) - { - return Err(invalid_request("invalid remote plugin id")); - } - - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; - let result = codex_core_plugins::remote::save_remote_plugin_share( - &remote_plugin_service_config, - auth.as_ref(), - config.codex_home.as_path(), - &plugin_path, - remote_plugin_id.as_deref(), - ) - .await - .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "save remote plugin share"))?; - let remote_plugin_id = result.remote_plugin_id; - self.clear_plugin_related_caches(); - Ok(PluginShareSaveResponse { - remote_plugin_id, - share_url: result.share_url.unwrap_or_default(), - }) - } - - pub(super) async fn plugin_share_list( - &self, - request_id: ConnectionRequestId, - _params: PluginShareListParams, - ) { - let result = self.plugin_share_list_response().await; - self.outgoing.send_result(request_id, result).await; - } - - async fn plugin_share_list_response( - &self, - ) -> Result { - let (config, auth) = self.load_plugin_share_config_and_auth().await?; - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; - let data = codex_core_plugins::remote::list_remote_plugin_shares( - &remote_plugin_service_config, - auth.as_ref(), - config.codex_home.as_path(), - ) - .await - .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "list remote plugin shares"))? - .into_iter() - .map(|summary| { - let RemoteCatalogPluginShareSummary { - summary, - share_url, - local_plugin_path, - } = summary; - let plugin = remote_plugin_summary_to_info(summary); - PluginShareListItem { - plugin, - share_url: share_url.unwrap_or_default(), - local_plugin_path, - } - }) - .collect(); - Ok(PluginShareListResponse { data }) - } - - pub(super) async fn plugin_share_delete( - &self, - request_id: ConnectionRequestId, - params: PluginShareDeleteParams, - ) { - let result = self.plugin_share_delete_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn plugin_share_delete_response( - &self, - params: PluginShareDeleteParams, - ) -> Result { - let (config, auth) = self.load_plugin_share_config_and_auth().await?; - let PluginShareDeleteParams { remote_plugin_id } = params; - if remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(&remote_plugin_id) { - return Err(invalid_request("invalid remote plugin id")); - } - - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; - codex_core_plugins::remote::delete_remote_plugin_share( - &remote_plugin_service_config, - auth.as_ref(), - config.codex_home.as_path(), - &remote_plugin_id, - ) - .await - .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "delete remote plugin share"))?; - self.clear_plugin_related_caches(); - Ok(PluginShareDeleteResponse {}) - } - - async fn load_plugin_share_config_and_auth( - &self, - ) -> Result<(Config, Option), JSONRPCErrorError> { - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; - if !config.features.enabled(Feature::Plugins) - || !config.features.enabled(Feature::RemotePlugin) - { - return Err(invalid_request("plugin sharing is not enabled")); - } - let auth = self.auth_manager.auth().await; - Ok((config, auth)) - } - - pub(super) async fn plugin_install( - &self, - request_id: ConnectionRequestId, - params: PluginInstallParams, - ) { - let result = self.plugin_install_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn plugin_install_response( - &self, - params: PluginInstallParams, - ) -> Result { - let PluginInstallParams { - marketplace_path, - remote_marketplace_name, - plugin_name, - } = params; - let marketplace_path = match (marketplace_path, remote_marketplace_name) { - (Some(marketplace_path), None) => marketplace_path, - (None, Some(remote_marketplace_name)) => { - return self - .remote_plugin_install_response(remote_marketplace_name, plugin_name) - .await; - } - (Some(_), Some(_)) | (None, None) => { - return Err(invalid_request( - "plugin/install requires exactly one of marketplacePath or remoteMarketplaceName", - )); - } - }; - let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf); - let config = self.load_latest_config(config_cwd.clone()).await?; - let auth = self.auth_manager.auth().await; - - if !self - .workspace_codex_plugins_enabled(&config, auth.as_ref()) - .await - { - return Err(invalid_request( - "Codex plugins are disabled for this workspace", - )); - } - - let plugins_manager = self.thread_manager.plugins_manager(); - let request = PluginInstallRequest { - plugin_name, - marketplace_path, - }; - - let result = plugins_manager - .install_plugin(request) - .await - .map_err(Self::plugin_install_error)?; - let config = match self.load_latest_config(config_cwd).await { - Ok(config) => config, - Err(err) => { - warn!( - "failed to reload config after plugin install, using current config: {err:?}" - ); - config - } - }; - - self.on_effective_plugins_changed(config.clone()); - - let plugin_mcp_servers = load_plugin_mcp_servers(result.installed_path.as_path()).await; - if !plugin_mcp_servers.is_empty() { - self.start_plugin_mcp_oauth_logins(&config, plugin_mcp_servers) - .await; - } - - let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await; - let auth = self.auth_manager.auth().await; - let apps_needing_auth = self - .plugin_apps_needing_auth_for_install( - &config, - auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth), - &result.plugin_id.as_key(), - &plugin_apps, - ) - .await; - - Ok(PluginInstallResponse { - auth_policy: result.auth_policy.into(), - apps_needing_auth, - }) - } - - async fn remote_plugin_install_response( - &self, - remote_marketplace_name: String, - remote_plugin_id: String, - ) -> Result { - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; - if !config.features.enabled(Feature::Plugins) - || !config.features.enabled(Feature::RemotePlugin) - { - return Err(invalid_request(format!( - "remote plugin install is not enabled for marketplace {remote_marketplace_name}" - ))); - } - validate_remote_plugin_id(&remote_plugin_id)?; - - let auth = self.auth_manager.auth().await; - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; - let remote_detail = - codex_core_plugins::remote::fetch_remote_plugin_detail_with_download_urls( - &remote_plugin_service_config, - auth.as_ref(), - &remote_marketplace_name, - &remote_plugin_id, - ) - .await - .map_err(|err| { - remote_plugin_catalog_error_to_jsonrpc( - err, - "read remote plugin details before install", - ) - })?; - if remote_detail.summary.availability == PluginAvailability::DisabledByAdmin { - let remote_plugin_id = &remote_detail.summary.id; - return Err(invalid_request(format!( - "remote plugin {remote_plugin_id} is disabled by admin" - ))); - } - if remote_detail.summary.install_policy == PluginInstallPolicy::NotAvailable { - return Err(invalid_request(format!( - "remote plugin {remote_plugin_id} is not available for install" - ))); - } - let actual_remote_marketplace_name = remote_detail.marketplace_name.clone(); - // Direct install writes the same cache tree that installed-plugin sync - // prunes before the backend installed snapshot can include this plugin. - let _remote_plugin_cache_mutation = - codex_core_plugins::remote::mark_remote_plugin_cache_mutation_in_flight( - config.codex_home.as_path(), - &actual_remote_marketplace_name, - &remote_detail.summary.name, - ); - let validated_bundle = codex_core_plugins::remote_bundle::validate_remote_plugin_bundle( - &remote_plugin_id, - &actual_remote_marketplace_name, - &remote_detail.summary.name, - remote_detail.release_version.as_deref(), - remote_detail.bundle_download_url.as_deref(), - ) - .map_err(remote_plugin_bundle_install_error_to_jsonrpc)?; - - let result = codex_core_plugins::remote_bundle::download_and_install_remote_plugin_bundle( - config.codex_home.to_path_buf(), - validated_bundle, - ) - .await - .map_err(remote_plugin_bundle_install_error_to_jsonrpc)?; - - // Cache first so a backend install cannot succeed when local materialization fails. - // If this backend call fails, the cache entry is harmless because remote installed state - // is still backend-gated. - codex_core_plugins::remote::install_remote_plugin( - &remote_plugin_service_config, - auth.as_ref(), - &actual_remote_marketplace_name, - &remote_plugin_id, - ) - .await - .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin"))?; - - self.thread_manager - .plugins_manager() - .maybe_start_remote_installed_plugins_cache_refresh_after_mutation( - &config.plugins_config_input(), - auth.clone(), - Some(self.effective_plugins_changed_callback(config.clone())), - ); - - let mut plugin_metadata = - plugin_telemetry_metadata_from_root(&result.plugin_id, &result.installed_path).await; - plugin_metadata.remote_plugin_id = Some(remote_plugin_id); - self.analytics_events_client - .track_plugin_installed(plugin_metadata); - - let plugin_mcp_servers = load_plugin_mcp_servers(result.installed_path.as_path()).await; - if !plugin_mcp_servers.is_empty() { - self.start_plugin_mcp_oauth_logins(&config, plugin_mcp_servers) - .await; - } - - let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await; - let apps_needing_auth = self - .plugin_apps_needing_auth_for_install( - &config, - auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth), - &result.plugin_id.as_key(), - &plugin_apps, - ) - .await; - - Ok(PluginInstallResponse { - auth_policy: remote_detail.summary.auth_policy, - apps_needing_auth, - }) - } - - async fn plugin_apps_needing_auth_for_install( - &self, - config: &Config, - is_chatgpt_auth: bool, - plugin_id: &str, - plugin_apps: &[codex_plugin::AppConnectorId], - ) -> Vec { - if plugin_apps.is_empty() || !config.features.apps_enabled_for_auth(is_chatgpt_auth) { - return Vec::new(); - } - - let environment_manager = self.thread_manager.environment_manager(); - let (all_connectors_result, accessible_connectors_result) = tokio::join!( - connectors::list_all_connectors_with_options(config, /*force_refetch*/ true), - connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( - config, - /*force_refetch*/ true, - &environment_manager - ), - ); - - let all_connectors = match all_connectors_result { - Ok(connectors) => connectors, - Err(err) => { - warn!( - plugin = plugin_id, - "failed to load app metadata after plugin install: {err:#}" - ); - connectors::list_cached_all_connectors(config) - .await - .unwrap_or_default() - } - }; - let all_connectors = connectors::connectors_for_plugin_apps(all_connectors, plugin_apps); - let (accessible_connectors, codex_apps_ready) = match accessible_connectors_result { - Ok(status) => (status.connectors, status.codex_apps_ready), - Err(err) => { - warn!( - plugin = plugin_id, - "failed to load accessible apps after plugin install: {err:#}" - ); - ( - connectors::list_cached_accessible_connectors_from_mcp_tools(config) - .await - .unwrap_or_default(), - false, - ) - } - }; - if !codex_apps_ready { - warn!( - plugin = plugin_id, - "codex_apps MCP not ready after plugin install; skipping appsNeedingAuth check" - ); - } - - plugin_app_helpers::plugin_apps_needing_auth( - &all_connectors, - &accessible_connectors, - plugin_apps, - codex_apps_ready, - ) - } - - pub(super) async fn plugin_uninstall( - &self, - request_id: ConnectionRequestId, - params: PluginUninstallParams, - ) { - let result = self.plugin_uninstall_response(params).await; - self.outgoing.send_result(request_id, result).await; - } - - async fn plugin_uninstall_response( - &self, - params: PluginUninstallParams, - ) -> Result { - let PluginUninstallParams { plugin_id } = params; - if codex_plugin::PluginId::parse(&plugin_id).is_err() - && !is_valid_remote_uninstall_plugin_id(&plugin_id) - { - return Err(invalid_request( - "invalid plugin id: expected a local plugin id in the form `plugin@marketplace` or a remote plugin id starting with `plugins~`, `plugins_`, `app_`, `asdk_app_`, or `connector_`", - )); - } - if is_valid_remote_uninstall_plugin_id(&plugin_id) { - return self.remote_plugin_uninstall_response(plugin_id).await; - } - let plugins_manager = self.thread_manager.plugins_manager(); - - plugins_manager - .uninstall_plugin(plugin_id) - .await - .map_err(Self::plugin_uninstall_error)?; - match self.load_latest_config(/*fallback_cwd*/ None).await { - Ok(config) => self.on_effective_plugins_changed(config), - Err(err) => { - warn!( - "failed to reload config after plugin uninstall, clearing plugin-related caches only: {err:?}" - ); - self.clear_plugin_related_caches(); - } - } - Ok(PluginUninstallResponse {}) - } - - fn plugin_install_error(err: CorePluginInstallError) -> JSONRPCErrorError { - if err.is_invalid_request() { - return invalid_request(err.to_string()); - } - - match err { - CorePluginInstallError::Marketplace(err) => { - Self::marketplace_error(err, "install plugin") - } - CorePluginInstallError::Config(err) => { - internal_error(format!("failed to persist installed plugin config: {err}")) - } - CorePluginInstallError::Remote(err) => { - internal_error(format!("failed to enable remote plugin: {err}")) - } - CorePluginInstallError::Join(err) => { - internal_error(format!("failed to install plugin: {err}")) - } - CorePluginInstallError::Store(err) => { - internal_error(format!("failed to install plugin: {err}")) - } - } - } - - fn plugin_uninstall_error(err: CorePluginUninstallError) -> JSONRPCErrorError { - if err.is_invalid_request() { - return invalid_request(err.to_string()); - } - - match err { - CorePluginUninstallError::Config(err) => { - internal_error(format!("failed to clear plugin config: {err}")) - } - CorePluginUninstallError::Remote(err) => { - internal_error(format!("failed to uninstall remote plugin: {err}")) - } - CorePluginUninstallError::Join(err) => { - internal_error(format!("failed to uninstall plugin: {err}")) - } - CorePluginUninstallError::Store(err) => { - internal_error(format!("failed to uninstall plugin: {err}")) - } - CorePluginUninstallError::InvalidPluginId(_) => { - unreachable!("invalid plugin ids are handled above"); - } - } - } - - fn marketplace_error(err: MarketplaceError, action: &str) -> JSONRPCErrorError { - match err { - MarketplaceError::MarketplaceNotFound { .. } - | MarketplaceError::InvalidMarketplaceFile { .. } - | MarketplaceError::PluginNotFound { .. } - | MarketplaceError::PluginNotAvailable { .. } - | MarketplaceError::PluginsDisabled - | MarketplaceError::InvalidPlugin(_) => invalid_request(err.to_string()), - MarketplaceError::Io { .. } => internal_error(format!("failed to {action}: {err}")), - } - } - - async fn remote_plugin_uninstall_response( - &self, - plugin_id: String, - ) -> Result { - let config = self.load_latest_config(/*fallback_cwd*/ None).await?; - if !config.features.enabled(Feature::Plugins) - || !config.features.enabled(Feature::RemotePlugin) - { - return Err(invalid_request("remote plugin uninstall is not enabled")); - } - validate_remote_plugin_id(&plugin_id)?; - - let auth = self.auth_manager.auth().await; - let remote_plugin_service_config = RemotePluginServiceConfig { - chatgpt_base_url: config.chatgpt_base_url.clone(), - }; - let uninstall_result = codex_core_plugins::remote::uninstall_remote_plugin( - &remote_plugin_service_config, - auth.as_ref(), - config.codex_home.to_path_buf(), - &plugin_id, - ) - .await; - - if matches!( - &uninstall_result, - Ok(()) | Err(RemotePluginCatalogError::CacheRemove(_)) - ) { - let plugins_manager = self.thread_manager.plugins_manager(); - if plugins_manager.clear_remote_installed_plugins_cache() { - self.on_effective_plugins_changed(config.clone()); - } - plugins_manager.maybe_start_remote_installed_plugins_cache_refresh_after_mutation( - &config.plugins_config_input(), - auth.clone(), - Some(self.effective_plugins_changed_callback(config.clone())), - ); - } - - uninstall_result.map_err(|err| { - remote_plugin_catalog_error_to_jsonrpc(err, "uninstall remote plugin") - })?; - Ok(PluginUninstallResponse {}) - } -} - -fn is_valid_remote_uninstall_plugin_id(plugin_name: &str) -> bool { - is_valid_remote_plugin_id(plugin_name) - && (plugin_name.starts_with("plugins~") - || plugin_name.starts_with("plugins_") - || plugin_name.starts_with("app_") - || plugin_name.starts_with("asdk_app_") - || plugin_name.starts_with("connector_")) -} - -fn remote_marketplace_to_info(marketplace: RemoteMarketplace) -> PluginMarketplaceEntry { - PluginMarketplaceEntry { - name: marketplace.name, - path: None, - interface: Some(MarketplaceInterface { - display_name: Some(marketplace.display_name), - }), - plugins: marketplace - .plugins - .into_iter() - .map(remote_plugin_summary_to_info) - .collect(), - } -} - -fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginSummary { - PluginSummary { - id: summary.id, - name: summary.name, - source: PluginSource::Remote, - installed: summary.installed, - enabled: summary.enabled, - install_policy: summary.install_policy, - auth_policy: summary.auth_policy, - availability: summary.availability, - interface: summary.interface, - } -} - -fn remote_plugin_detail_to_info( - detail: RemoteCatalogPluginDetail, - apps: Vec, -) -> PluginDetail { - PluginDetail { - marketplace_name: detail.marketplace_name, - marketplace_path: None, - summary: remote_plugin_summary_to_info(detail.summary), - description: detail.description, - skills: detail - .skills - .into_iter() - .map(|skill| SkillSummary { - name: skill.name, - description: skill.description, - short_description: skill.short_description, - interface: skill.interface, - path: None, - enabled: skill.enabled, - }) - .collect(), - apps, - mcp_servers: Vec::new(), - } -} - -fn remote_plugin_catalog_error_to_jsonrpc( - err: RemotePluginCatalogError, - context: &str, -) -> JSONRPCErrorError { - match err { - RemotePluginCatalogError::AuthRequired | RemotePluginCatalogError::UnsupportedAuthMode => { - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("{context}: {err}"), - data: None, - } - } - RemotePluginCatalogError::UnexpectedStatus { status, .. } if status.as_u16() == 404 => { - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("{context}: {err}"), - data: None, - } - } - RemotePluginCatalogError::InvalidPluginPath { .. } - | RemotePluginCatalogError::ArchiveTooLarge { .. } - | RemotePluginCatalogError::UnknownMarketplace { .. } => JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("{context}: {err}"), - data: None, - }, - RemotePluginCatalogError::AuthToken(_) - | RemotePluginCatalogError::Request { .. } - | RemotePluginCatalogError::UnexpectedStatus { .. } - | RemotePluginCatalogError::Decode { .. } - | RemotePluginCatalogError::InvalidBaseUrl(_) - | RemotePluginCatalogError::InvalidBaseUrlPath - | RemotePluginCatalogError::UnexpectedPluginId { .. } - | RemotePluginCatalogError::UnexpectedSkillName { .. } - | RemotePluginCatalogError::UnexpectedEnabledState { .. } - | RemotePluginCatalogError::Archive { .. } - | RemotePluginCatalogError::ArchiveJoin(_) - | RemotePluginCatalogError::MissingUploadEtag - | RemotePluginCatalogError::UnexpectedResponse(_) - | RemotePluginCatalogError::CacheRemove(_) => JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("{context}: {err}"), - data: None, - }, - } -} - -fn remote_plugin_bundle_install_error_to_jsonrpc( - err: codex_core_plugins::remote_bundle::RemotePluginBundleInstallError, -) -> JSONRPCErrorError { - internal_error(format!("install remote plugin bundle: {err}")) -} diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index 699556dd5b..443117e592 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -477,7 +477,7 @@ async fn run_command(params: RunCommandParams) { }); let stderr_handle = spawn_process_output(SpawnProcessOutputParams { connection_id: request_id.connection_id, - process_id, + process_id: process_id.clone(), output_rx: stderr_rx, stdio_timeout_rx, outgoing: Arc::clone(&outgoing), diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs deleted file mode 100644 index 4b6cbdd193..0000000000 --- a/codex-rs/app-server/src/config_api.rs +++ /dev/null @@ -1,874 +0,0 @@ -use crate::config_manager::ConfigManager; -use crate::config_manager_service::ConfigManagerError; -use crate::error_code::INVALID_REQUEST_ERROR_CODE; -use crate::error_code::internal_error; -use crate::error_code::invalid_request; -use async_trait::async_trait; -use codex_analytics::AnalyticsEventsClient; -use codex_app_server_protocol::ConfigBatchWriteParams; -use codex_app_server_protocol::ConfigReadParams; -use codex_app_server_protocol::ConfigReadResponse; -use codex_app_server_protocol::ConfigRequirements; -use codex_app_server_protocol::ConfigRequirementsReadResponse; -use codex_app_server_protocol::ConfigValueWriteParams; -use codex_app_server_protocol::ConfigWriteErrorCode; -use codex_app_server_protocol::ConfigWriteResponse; -use codex_app_server_protocol::ConfiguredHookHandler; -use codex_app_server_protocol::ConfiguredHookMatcherGroup; -use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; -use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse; -use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::ManagedHooksRequirements; -use codex_app_server_protocol::NetworkDomainPermission; -use codex_app_server_protocol::NetworkRequirements; -use codex_app_server_protocol::NetworkUnixSocketPermission; -use codex_app_server_protocol::SandboxMode; -use codex_config::ConfigRequirementsToml; -use codex_config::HookEventsToml; -use codex_config::HookHandlerConfig as CoreHookHandlerConfig; -use codex_config::ManagedHooksRequirementsToml; -use codex_config::MatcherGroup as CoreMatcherGroup; -use codex_config::ResidencyRequirement as CoreResidencyRequirement; -use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement; -use codex_core::ThreadManager; -use codex_core::config::Config; -use codex_core_plugins::loader::installed_plugin_telemetry_metadata; -use codex_core_plugins::toggles::collect_plugin_enabled_candidates; -use codex_features::canonical_feature_for_key; -use codex_features::feature_for_key; -use codex_plugin::PluginId; -use codex_protocol::config_types::WebSearchMode; -use codex_protocol::protocol::Op; -use serde_json::json; -use std::path::PathBuf; -use std::sync::Arc; -use tracing::warn; - -const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ - "apps", - "memories", - "plugins", - "remote_control", - "tool_search", - "tool_suggest", - "tool_call_mcp_elicitation", -]; - -#[async_trait] -pub(crate) trait UserConfigReloader: Send + Sync { - async fn reload_user_config(&self); -} - -#[async_trait] -impl UserConfigReloader for ThreadManager { - async fn reload_user_config(&self) { - let thread_ids = self.list_thread_ids().await; - for thread_id in thread_ids { - let Ok(thread) = self.get_thread(thread_id).await else { - continue; - }; - if let Err(err) = thread.submit(Op::ReloadUserConfig).await { - warn!("failed to request user config reload: {err}"); - } - } - } -} - -#[derive(Clone)] -pub(crate) struct ConfigApi { - config_manager: ConfigManager, - user_config_reloader: Arc, - analytics_events_client: AnalyticsEventsClient, -} - -impl ConfigApi { - pub(crate) fn new( - config_manager: ConfigManager, - user_config_reloader: Arc, - analytics_events_client: AnalyticsEventsClient, - ) -> Self { - Self { - config_manager, - user_config_reloader, - analytics_events_client, - } - } - - pub(crate) async fn load_latest_config( - &self, - fallback_cwd: Option, - ) -> Result { - self.config_manager - .load_latest_config(fallback_cwd) - .await - .map_err(|err| { - internal_error(format!( - "failed to resolve feature override precedence: {err}" - )) - }) - } - - pub(crate) async fn read( - &self, - params: ConfigReadParams, - ) -> Result { - let fallback_cwd = params.cwd.as_ref().map(PathBuf::from); - let mut response = self.config_manager.read(params).await.map_err(map_error)?; - let config = self.load_latest_config(fallback_cwd).await?; - for feature_key in SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT { - let Some(feature) = feature_for_key(feature_key) else { - continue; - }; - let features = response - .config - .additional - .entry("features".to_string()) - .or_insert_with(|| json!({})); - if !features.is_object() { - *features = json!({}); - } - if let Some(features) = features.as_object_mut() { - features.insert( - (*feature_key).to_string(), - json!(config.features.enabled(feature)), - ); - } - } - Ok(response) - } - - pub(crate) async fn config_requirements_read( - &self, - ) -> Result { - let requirements = self - .config_manager - .read_requirements() - .await - .map_err(map_error)? - .map(map_requirements_toml_to_api); - - Ok(ConfigRequirementsReadResponse { requirements }) - } - - pub(crate) async fn write_value( - &self, - params: ConfigValueWriteParams, - ) -> Result { - let pending_changes = - collect_plugin_enabled_candidates([(¶ms.key_path, ¶ms.value)].into_iter()); - let response = self - .config_manager - .write_value(params) - .await - .map_err(map_error)?; - self.emit_plugin_toggle_events(pending_changes).await; - Ok(response) - } - - pub(crate) async fn batch_write( - &self, - params: ConfigBatchWriteParams, - ) -> Result { - let reload_user_config = params.reload_user_config; - let pending_changes = collect_plugin_enabled_candidates( - params - .edits - .iter() - .map(|edit| (&edit.key_path, &edit.value)), - ); - let response = self - .config_manager - .batch_write(params) - .await - .map_err(map_error)?; - self.emit_plugin_toggle_events(pending_changes).await; - if reload_user_config { - self.user_config_reloader.reload_user_config().await; - } - Ok(response) - } - - pub(crate) async fn set_experimental_feature_enablement( - &self, - params: ExperimentalFeatureEnablementSetParams, - ) -> Result { - let ExperimentalFeatureEnablementSetParams { enablement } = params; - for key in enablement.keys() { - if canonical_feature_for_key(key).is_some() { - if SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()) { - continue; - } - - return Err(invalid_request(format!( - "unsupported feature enablement `{key}`: currently supported features are {}", - SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ") - ))); - } - - let message = if let Some(feature) = feature_for_key(key) { - format!( - "invalid feature enablement `{key}`: use canonical feature key `{}`", - feature.key() - ) - } else { - format!("invalid feature enablement `{key}`") - }; - return Err(invalid_request(message)); - } - - if enablement.is_empty() { - return Ok(ExperimentalFeatureEnablementSetResponse { enablement }); - } - - self.config_manager - .extend_runtime_feature_enablement( - enablement - .iter() - .map(|(name, enabled)| (name.clone(), *enabled)), - ) - .map_err(|_| internal_error("failed to update feature enablement"))?; - - self.load_latest_config(/*fallback_cwd*/ None).await?; - self.user_config_reloader.reload_user_config().await; - - Ok(ExperimentalFeatureEnablementSetResponse { enablement }) - } - - async fn emit_plugin_toggle_events( - &self, - pending_changes: std::collections::BTreeMap, - ) { - for (plugin_id, enabled) in pending_changes { - let Ok(plugin_id) = PluginId::parse(&plugin_id) else { - continue; - }; - let metadata = - installed_plugin_telemetry_metadata(self.config_manager.codex_home(), &plugin_id) - .await; - if enabled { - self.analytics_events_client.track_plugin_enabled(metadata); - } else { - self.analytics_events_client.track_plugin_disabled(metadata); - } - } - } -} - -fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements { - ConfigRequirements { - allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| { - policies - .into_iter() - .map(codex_app_server_protocol::AskForApproval::from) - .collect() - }), - allowed_approvals_reviewers: requirements.allowed_approvals_reviewers.map(|reviewers| { - reviewers - .into_iter() - .map(codex_app_server_protocol::ApprovalsReviewer::from) - .collect() - }), - allowed_sandbox_modes: requirements.allowed_sandbox_modes.map(|modes| { - modes - .into_iter() - .filter_map(map_sandbox_mode_requirement_to_api) - .collect() - }), - allowed_web_search_modes: requirements.allowed_web_search_modes.map(|modes| { - let mut normalized = modes - .into_iter() - .map(Into::into) - .collect::>(); - if !normalized.contains(&WebSearchMode::Disabled) { - normalized.push(WebSearchMode::Disabled); - } - normalized - }), - feature_requirements: requirements - .feature_requirements - .map(|requirements| requirements.entries), - hooks: requirements.hooks.map(map_hooks_requirements_to_api), - enforce_residency: requirements - .enforce_residency - .map(map_residency_requirement_to_api), - network: requirements.network.map(map_network_requirements_to_api), - } -} - -fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> ManagedHooksRequirements { - let ManagedHooksRequirementsToml { - managed_dir, - windows_managed_dir, - hooks, - } = hooks; - let HookEventsToml { - pre_tool_use, - permission_request, - post_tool_use, - session_start, - user_prompt_submit, - stop, - } = hooks; - - ManagedHooksRequirements { - managed_dir, - windows_managed_dir, - pre_tool_use: map_hook_matcher_groups_to_api(pre_tool_use), - permission_request: map_hook_matcher_groups_to_api(permission_request), - post_tool_use: map_hook_matcher_groups_to_api(post_tool_use), - session_start: map_hook_matcher_groups_to_api(session_start), - user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit), - stop: map_hook_matcher_groups_to_api(stop), - } -} - -fn map_hook_matcher_groups_to_api( - groups: Vec, -) -> Vec { - groups - .into_iter() - .map(map_hook_matcher_group_to_api) - .collect() -} - -fn map_hook_matcher_group_to_api(group: CoreMatcherGroup) -> ConfiguredHookMatcherGroup { - ConfiguredHookMatcherGroup { - matcher: group.matcher, - hooks: group - .hooks - .into_iter() - .map(map_hook_handler_to_api) - .collect(), - } -} - -fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHandler { - match handler { - CoreHookHandlerConfig::Command { - command, - timeout_sec, - r#async, - status_message, - } => ConfiguredHookHandler::Command { - command, - timeout_sec, - r#async, - status_message, - }, - CoreHookHandlerConfig::Prompt {} => ConfiguredHookHandler::Prompt {}, - CoreHookHandlerConfig::Agent {} => ConfiguredHookHandler::Agent {}, - } -} - -fn map_sandbox_mode_requirement_to_api(mode: CoreSandboxModeRequirement) -> Option { - match mode { - CoreSandboxModeRequirement::ReadOnly => Some(SandboxMode::ReadOnly), - CoreSandboxModeRequirement::WorkspaceWrite => Some(SandboxMode::WorkspaceWrite), - CoreSandboxModeRequirement::DangerFullAccess => Some(SandboxMode::DangerFullAccess), - CoreSandboxModeRequirement::ExternalSandbox => None, - } -} - -fn map_residency_requirement_to_api( - residency: CoreResidencyRequirement, -) -> codex_app_server_protocol::ResidencyRequirement { - match residency { - CoreResidencyRequirement::Us => codex_app_server_protocol::ResidencyRequirement::Us, - } -} - -fn map_network_requirements_to_api( - network: codex_config::NetworkRequirementsToml, -) -> NetworkRequirements { - let allowed_domains = network - .domains - .as_ref() - .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains); - let denied_domains = network - .domains - .as_ref() - .and_then(codex_config::NetworkDomainPermissionsToml::denied_domains); - let allow_unix_sockets = network - .unix_sockets - .as_ref() - .map(codex_config::NetworkUnixSocketPermissionsToml::allow_unix_sockets) - .filter(|entries| !entries.is_empty()); - - NetworkRequirements { - enabled: network.enabled, - http_port: network.http_port, - socks_port: network.socks_port, - allow_upstream_proxy: network.allow_upstream_proxy, - dangerously_allow_non_loopback_proxy: network.dangerously_allow_non_loopback_proxy, - dangerously_allow_all_unix_sockets: network.dangerously_allow_all_unix_sockets, - domains: network.domains.map(|domains| { - domains - .entries - .into_iter() - .map(|(pattern, permission)| { - (pattern, map_network_domain_permission_to_api(permission)) - }) - .collect() - }), - managed_allowed_domains_only: network.managed_allowed_domains_only, - allowed_domains, - denied_domains, - unix_sockets: network.unix_sockets.map(|unix_sockets| { - unix_sockets - .entries - .into_iter() - .map(|(path, permission)| { - (path, map_network_unix_socket_permission_to_api(permission)) - }) - .collect() - }), - allow_unix_sockets, - allow_local_binding: network.allow_local_binding, - } -} - -fn map_network_domain_permission_to_api( - permission: codex_config::NetworkDomainPermissionToml, -) -> NetworkDomainPermission { - match permission { - codex_config::NetworkDomainPermissionToml::Allow => NetworkDomainPermission::Allow, - codex_config::NetworkDomainPermissionToml::Deny => NetworkDomainPermission::Deny, - } -} - -fn map_network_unix_socket_permission_to_api( - permission: codex_config::NetworkUnixSocketPermissionToml, -) -> NetworkUnixSocketPermission { - match permission { - codex_config::NetworkUnixSocketPermissionToml::Allow => NetworkUnixSocketPermission::Allow, - codex_config::NetworkUnixSocketPermissionToml::None => NetworkUnixSocketPermission::None, - } -} - -fn map_error(err: ConfigManagerError) -> JSONRPCErrorError { - if let Some(code) = err.write_error_code() { - return config_write_error(code, err.to_string()); - } - - internal_error(err.to_string()) -} - -fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> JSONRPCErrorError { - JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: message.into(), - data: Some(json!({ - "config_write_error_code": code, - })), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config_manager::apply_runtime_feature_enablement; - use codex_analytics::AnalyticsEventsClient; - use codex_arg0::Arg0DispatchPaths; - use codex_config::CloudRequirementsLoader; - use codex_config::LoaderOverrides; - use codex_config::NetworkDomainPermissionToml as CoreNetworkDomainPermissionToml; - use codex_config::NetworkDomainPermissionsToml as CoreNetworkDomainPermissionsToml; - use codex_config::NetworkRequirementsToml as CoreNetworkRequirementsToml; - use codex_config::NetworkUnixSocketPermissionToml as CoreNetworkUnixSocketPermissionToml; - use codex_config::NetworkUnixSocketPermissionsToml as CoreNetworkUnixSocketPermissionsToml; - use codex_features::Feature; - use codex_login::AuthManager; - use codex_login::CodexAuth; - use codex_protocol::config_types::ApprovalsReviewer as CoreApprovalsReviewer; - use codex_protocol::protocol::AskForApproval as CoreAskForApproval; - use pretty_assertions::assert_eq; - use serde_json::json; - use std::collections::BTreeMap; - use std::sync::atomic::AtomicUsize; - use std::sync::atomic::Ordering; - use tempfile::TempDir; - use toml::Value as TomlValue; - - #[derive(Default)] - struct RecordingUserConfigReloader { - call_count: AtomicUsize, - } - - #[async_trait] - impl UserConfigReloader for RecordingUserConfigReloader { - async fn reload_user_config(&self) { - self.call_count.fetch_add(1, Ordering::Relaxed); - } - } - - #[test] - fn map_requirements_toml_to_api_converts_core_enums() { - let requirements = ConfigRequirementsToml { - allowed_approval_policies: Some(vec![ - CoreAskForApproval::Never, - CoreAskForApproval::OnRequest, - ]), - allowed_approvals_reviewers: Some(vec![ - CoreApprovalsReviewer::User, - CoreApprovalsReviewer::AutoReview, - ]), - allowed_sandbox_modes: Some(vec![ - CoreSandboxModeRequirement::ReadOnly, - CoreSandboxModeRequirement::ExternalSandbox, - ]), - remote_sandbox_config: None, - allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]), - guardian_policy_config: None, - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: std::collections::BTreeMap::from([ - ("apps".to_string(), false), - ("personality".to_string(), true), - ]), - }), - hooks: Some(ManagedHooksRequirementsToml { - managed_dir: Some(PathBuf::from("/enterprise/hooks")), - windows_managed_dir: Some(PathBuf::from(r"C:\enterprise\hooks")), - hooks: HookEventsToml { - pre_tool_use: vec![CoreMatcherGroup { - matcher: Some("^Bash$".to_string()), - hooks: vec![CoreHookHandlerConfig::Command { - command: "python3 /enterprise/hooks/pre.py".to_string(), - timeout_sec: Some(10), - r#async: false, - status_message: Some("checking".to_string()), - }], - }], - ..Default::default() - }, - }), - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: Some(CoreResidencyRequirement::Us), - network: Some(CoreNetworkRequirementsToml { - enabled: Some(true), - http_port: Some(8080), - socks_port: Some(1080), - allow_upstream_proxy: Some(false), - dangerously_allow_non_loopback_proxy: Some(false), - dangerously_allow_all_unix_sockets: Some(true), - domains: Some(CoreNetworkDomainPermissionsToml { - entries: std::collections::BTreeMap::from([ - ( - "api.openai.com".to_string(), - CoreNetworkDomainPermissionToml::Allow, - ), - ( - "example.com".to_string(), - CoreNetworkDomainPermissionToml::Deny, - ), - ]), - }), - managed_allowed_domains_only: Some(false), - unix_sockets: Some(CoreNetworkUnixSocketPermissionsToml { - entries: std::collections::BTreeMap::from([( - "/tmp/proxy.sock".to_string(), - CoreNetworkUnixSocketPermissionToml::Allow, - )]), - }), - allow_local_binding: Some(true), - }), - permissions: None, - }; - - let mapped = map_requirements_toml_to_api(requirements); - - assert_eq!( - mapped.allowed_approval_policies, - Some(vec![ - codex_app_server_protocol::AskForApproval::Never, - codex_app_server_protocol::AskForApproval::OnRequest, - ]) - ); - assert_eq!( - mapped.allowed_approvals_reviewers, - Some(vec![ - codex_app_server_protocol::ApprovalsReviewer::User, - codex_app_server_protocol::ApprovalsReviewer::AutoReview, - ]) - ); - assert_eq!( - mapped.allowed_sandbox_modes, - Some(vec![SandboxMode::ReadOnly]), - ); - assert_eq!( - mapped.allowed_web_search_modes, - Some(vec![WebSearchMode::Cached, WebSearchMode::Disabled]), - ); - assert_eq!( - mapped.feature_requirements, - Some(std::collections::BTreeMap::from([ - ("apps".to_string(), false), - ("personality".to_string(), true), - ])), - ); - assert_eq!( - mapped.hooks, - Some(ManagedHooksRequirements { - managed_dir: Some(PathBuf::from("/enterprise/hooks")), - windows_managed_dir: Some(PathBuf::from(r"C:\enterprise\hooks")), - pre_tool_use: vec![ConfiguredHookMatcherGroup { - matcher: Some("^Bash$".to_string()), - hooks: vec![ConfiguredHookHandler::Command { - command: "python3 /enterprise/hooks/pre.py".to_string(), - timeout_sec: Some(10), - r#async: false, - status_message: Some("checking".to_string()), - }], - }], - permission_request: Vec::new(), - post_tool_use: Vec::new(), - session_start: Vec::new(), - user_prompt_submit: Vec::new(), - stop: Vec::new(), - }), - ); - assert_eq!( - mapped.enforce_residency, - Some(codex_app_server_protocol::ResidencyRequirement::Us), - ); - assert_eq!( - mapped.network, - Some(NetworkRequirements { - enabled: Some(true), - http_port: Some(8080), - socks_port: Some(1080), - allow_upstream_proxy: Some(false), - dangerously_allow_non_loopback_proxy: Some(false), - dangerously_allow_all_unix_sockets: Some(true), - domains: Some(std::collections::BTreeMap::from([ - ("api.openai.com".to_string(), NetworkDomainPermission::Allow,), - ("example.com".to_string(), NetworkDomainPermission::Deny), - ])), - managed_allowed_domains_only: Some(false), - allowed_domains: Some(vec!["api.openai.com".to_string()]), - denied_domains: Some(vec!["example.com".to_string()]), - unix_sockets: Some(std::collections::BTreeMap::from([( - "/tmp/proxy.sock".to_string(), - NetworkUnixSocketPermission::Allow, - )])), - allow_unix_sockets: Some(vec!["/tmp/proxy.sock".to_string()]), - allow_local_binding: Some(true), - }), - ); - } - - #[test] - fn map_requirements_toml_to_api_omits_unix_socket_none_entries_from_legacy_network_fields() { - let requirements = ConfigRequirementsToml { - allowed_approval_policies: None, - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - remote_sandbox_config: None, - allowed_web_search_modes: None, - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: Some(CoreNetworkRequirementsToml { - enabled: None, - http_port: None, - socks_port: None, - allow_upstream_proxy: None, - dangerously_allow_non_loopback_proxy: None, - dangerously_allow_all_unix_sockets: None, - domains: None, - managed_allowed_domains_only: None, - unix_sockets: Some(CoreNetworkUnixSocketPermissionsToml { - entries: std::collections::BTreeMap::from([( - "/tmp/ignored.sock".to_string(), - CoreNetworkUnixSocketPermissionToml::None, - )]), - }), - allow_local_binding: None, - }), - permissions: None, - }; - - let mapped = map_requirements_toml_to_api(requirements); - - assert_eq!( - mapped.network, - Some(NetworkRequirements { - enabled: None, - http_port: None, - socks_port: None, - allow_upstream_proxy: None, - dangerously_allow_non_loopback_proxy: None, - dangerously_allow_all_unix_sockets: None, - domains: None, - managed_allowed_domains_only: None, - allowed_domains: None, - denied_domains: None, - unix_sockets: Some(std::collections::BTreeMap::from([( - "/tmp/ignored.sock".to_string(), - NetworkUnixSocketPermission::None, - )])), - allow_unix_sockets: None, - allow_local_binding: None, - }), - ); - } - - #[test] - fn map_requirements_toml_to_api_normalizes_allowed_web_search_modes() { - let requirements = ConfigRequirementsToml { - allowed_approval_policies: None, - allowed_approvals_reviewers: None, - allowed_sandbox_modes: None, - remote_sandbox_config: None, - allowed_web_search_modes: Some(Vec::new()), - guardian_policy_config: None, - feature_requirements: None, - hooks: None, - mcp_servers: None, - plugins: None, - apps: None, - rules: None, - enforce_residency: None, - network: None, - permissions: None, - }; - - let mapped = map_requirements_toml_to_api(requirements); - - assert_eq!( - mapped.allowed_web_search_modes, - Some(vec![WebSearchMode::Disabled]) - ); - } - - #[tokio::test] - async fn apply_runtime_feature_enablement_keeps_cli_overrides_above_config_and_runtime() { - let codex_home = TempDir::new().expect("create temp dir"); - std::fs::write( - codex_home.path().join("config.toml"), - "[features]\napps = false\n", - ) - .expect("write config"); - - let mut config = codex_core::config::ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .fallback_cwd(Some(codex_home.path().to_path_buf())) - .cli_overrides(vec![( - "features.apps".to_string(), - TomlValue::Boolean(true), - )]) - .build() - .await - .expect("load config"); - - apply_runtime_feature_enablement( - &mut config, - &BTreeMap::from([("apps".to_string(), false)]), - ); - - assert!(config.features.enabled(Feature::Apps)); - } - - #[tokio::test] - async fn apply_runtime_feature_enablement_keeps_cloud_pins_above_cli_and_runtime() { - let codex_home = TempDir::new().expect("create temp dir"); - - let mut config = codex_core::config::ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .cli_overrides(vec![( - "features.apps".to_string(), - TomlValue::Boolean(true), - )]) - .cloud_requirements(CloudRequirementsLoader::new(async { - Ok(Some(ConfigRequirementsToml { - feature_requirements: Some(codex_config::FeatureRequirementsToml { - entries: BTreeMap::from([("apps".to_string(), false)]), - }), - ..Default::default() - })) - })) - .build() - .await - .expect("load config"); - - apply_runtime_feature_enablement( - &mut config, - &BTreeMap::from([("apps".to_string(), true)]), - ); - - assert!(!config.features.enabled(Feature::Apps)); - } - - #[tokio::test] - async fn batch_write_reloads_user_config_when_requested() { - let codex_home = TempDir::new().expect("create temp dir"); - let user_config_path = codex_home.path().join("config.toml"); - std::fs::write(&user_config_path, "").expect("write config"); - let reloader = Arc::new(RecordingUserConfigReloader::default()); - let analytics_config = Arc::new( - codex_core::config::ConfigBuilder::default() - .build() - .await - .expect("load analytics config"), - ); - let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); - let config_api = ConfigApi::new( - ConfigManager::new( - codex_home.path().to_path_buf(), - Vec::new(), - LoaderOverrides::default(), - CloudRequirementsLoader::default(), - Arg0DispatchPaths::default(), - Arc::new(codex_config::NoopThreadConfigLoader), - ), - reloader.clone(), - AnalyticsEventsClient::new( - auth_manager, - analytics_config - .chatgpt_base_url - .trim_end_matches('/') - .to_string(), - analytics_config.analytics_enabled, - ), - ); - - let response = config_api - .batch_write(ConfigBatchWriteParams { - edits: vec![codex_app_server_protocol::ConfigEdit { - key_path: "model".to_string(), - value: json!("gpt-5"), - merge_strategy: codex_app_server_protocol::MergeStrategy::Replace, - }], - file_path: Some(user_config_path.display().to_string()), - expected_version: None, - reload_user_config: true, - }) - .await - .expect("batch write should succeed"); - - assert_eq!( - response, - ConfigWriteResponse { - status: codex_app_server_protocol::WriteStatus::Ok, - version: response.version.clone(), - file_path: codex_utils_absolute_path::AbsolutePathBuf::try_from( - user_config_path.clone() - ) - .expect("absolute config path"), - overridden_metadata: None, - } - ); - assert_eq!( - std::fs::read_to_string(user_config_path).unwrap(), - "model = \"gpt-5\"\n" - ); - assert_eq!(reloader.call_count.load(Ordering::Relaxed), 1); - } -} diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index ba11205b7a..030829fa4b 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -140,6 +140,21 @@ impl ConfigManager { .await } + pub(crate) async fn load_latest_config_for_thread( + &self, + thread_config: &Config, + ) -> std::io::Result { + let refreshed_config = self + .load_latest_config(Some(thread_config.cwd.to_path_buf())) + .await?; + let mut config = thread_config + .rebuild_preserving_session_layers(&refreshed_config) + .await?; + self.apply_runtime_feature_enablement(&mut config); + self.apply_arg0_paths(&mut config); + Ok(config) + } + pub(crate) async fn load_default_config(&self) -> std::io::Result { let mut config = Config::load_default_with_cli_overrides_for_codex_home( self.codex_home.clone(), diff --git a/codex-rs/app-server/src/device_key_api.rs b/codex-rs/app-server/src/device_key_api.rs deleted file mode 100644 index b3d31426d1..0000000000 --- a/codex-rs/app-server/src/device_key_api.rs +++ /dev/null @@ -1,314 +0,0 @@ -use crate::error_code::internal_error; -use crate::error_code::invalid_request; -use async_trait::async_trait; -use base64::Engine; -use base64::engine::general_purpose::STANDARD; -use codex_app_server_protocol::DeviceKeyAlgorithm; -use codex_app_server_protocol::DeviceKeyCreateParams; -use codex_app_server_protocol::DeviceKeyCreateResponse; -use codex_app_server_protocol::DeviceKeyProtectionClass; -use codex_app_server_protocol::DeviceKeyPublicParams; -use codex_app_server_protocol::DeviceKeyPublicResponse; -use codex_app_server_protocol::DeviceKeySignParams; -use codex_app_server_protocol::DeviceKeySignPayload; -use codex_app_server_protocol::DeviceKeySignResponse; -use codex_app_server_protocol::JSONRPCErrorError; -use codex_device_key::DeviceKeyBinding; -use codex_device_key::DeviceKeyBindingStore; -use codex_device_key::DeviceKeyCreateRequest; -use codex_device_key::DeviceKeyError; -use codex_device_key::DeviceKeyGetPublicRequest; -use codex_device_key::DeviceKeyInfo; -use codex_device_key::DeviceKeyProtectionPolicy; -use codex_device_key::DeviceKeySignRequest; -use codex_device_key::DeviceKeyStore; -use codex_device_key::RemoteControlClientConnectionAudience; -use codex_device_key::RemoteControlClientConnectionSignPayload; -use codex_device_key::RemoteControlClientEnrollmentAudience; -use codex_device_key::RemoteControlClientEnrollmentSignPayload; -use codex_state::DeviceKeyBindingRecord; -use codex_state::StateRuntime; -use std::fmt; -use std::path::PathBuf; -use std::sync::Arc; -use tokio::sync::OnceCell; - -#[derive(Clone)] -pub(crate) struct DeviceKeyApi { - store: DeviceKeyStore, -} - -impl DeviceKeyApi { - pub(crate) fn new(sqlite_home: PathBuf, default_provider: String) -> Self { - Self { - store: DeviceKeyStore::new(Arc::new(StateDeviceKeyBindingStore::new( - sqlite_home, - default_provider, - ))), - } - } - - pub(crate) async fn create( - &self, - params: DeviceKeyCreateParams, - ) -> Result { - let info = self - .store - .create(DeviceKeyCreateRequest { - protection_policy: protection_policy_from_params(params.protection_policy), - binding: DeviceKeyBinding { - account_user_id: params.account_user_id, - client_id: params.client_id, - }, - }) - .await - .map_err(map_device_key_error)?; - Ok(create_response_from_info(info)) - } - - pub(crate) async fn public( - &self, - params: DeviceKeyPublicParams, - ) -> Result { - let info = self - .store - .get_public(DeviceKeyGetPublicRequest { - key_id: params.key_id, - }) - .await - .map_err(map_device_key_error)?; - Ok(public_response_from_info(info)) - } - - pub(crate) async fn sign( - &self, - params: DeviceKeySignParams, - ) -> Result { - let signature = self - .store - .sign(DeviceKeySignRequest { - key_id: params.key_id, - payload: payload_from_params(params.payload), - }) - .await - .map_err(map_device_key_error)?; - Ok(DeviceKeySignResponse { - signature_der_base64: STANDARD.encode(signature.signature_der), - signed_payload_base64: STANDARD.encode(signature.signed_payload), - algorithm: algorithm_from_store(signature.algorithm), - }) - } -} - -struct StateDeviceKeyBindingStore { - sqlite_home: PathBuf, - default_provider: String, - state_db: OnceCell>, -} - -impl StateDeviceKeyBindingStore { - fn new(sqlite_home: PathBuf, default_provider: String) -> Self { - Self { - sqlite_home, - default_provider, - state_db: OnceCell::new(), - } - } - - async fn state_db(&self) -> Result, DeviceKeyError> { - let sqlite_home = self.sqlite_home.clone(); - let default_provider = self.default_provider.clone(); - self.state_db - .get_or_try_init(|| async move { - StateRuntime::init(sqlite_home, default_provider) - .await - .map_err(|err| DeviceKeyError::Platform(err.to_string())) - }) - .await - .cloned() - } -} - -impl fmt::Debug for StateDeviceKeyBindingStore { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("StateDeviceKeyBindingStore") - .field("sqlite_home", &self.sqlite_home) - .field("default_provider", &self.default_provider) - .finish_non_exhaustive() - } -} - -#[async_trait] -impl DeviceKeyBindingStore for StateDeviceKeyBindingStore { - async fn get_binding(&self, key_id: &str) -> Result, DeviceKeyError> { - let state_db = self.state_db().await?; - state_db - .get_device_key_binding(key_id) - .await - .map(|record| { - record.map(|record| DeviceKeyBinding { - account_user_id: record.account_user_id, - client_id: record.client_id, - }) - }) - .map_err(|err| DeviceKeyError::Platform(err.to_string())) - } - - async fn put_binding( - &self, - key_id: &str, - binding: &DeviceKeyBinding, - ) -> Result<(), DeviceKeyError> { - let state_db = self.state_db().await?; - state_db - .upsert_device_key_binding(&DeviceKeyBindingRecord { - key_id: key_id.to_string(), - account_user_id: binding.account_user_id.clone(), - client_id: binding.client_id.clone(), - }) - .await - .map_err(|err| DeviceKeyError::Platform(err.to_string())) - } -} - -fn create_response_from_info(info: DeviceKeyInfo) -> DeviceKeyCreateResponse { - DeviceKeyCreateResponse { - key_id: info.key_id, - public_key_spki_der_base64: STANDARD.encode(info.public_key_spki_der), - algorithm: algorithm_from_store(info.algorithm), - protection_class: protection_class_from_store(info.protection_class), - } -} - -fn public_response_from_info(info: DeviceKeyInfo) -> DeviceKeyPublicResponse { - DeviceKeyPublicResponse { - key_id: info.key_id, - public_key_spki_der_base64: STANDARD.encode(info.public_key_spki_der), - algorithm: algorithm_from_store(info.algorithm), - protection_class: protection_class_from_store(info.protection_class), - } -} - -fn protection_policy_from_params( - protection_policy: Option, -) -> DeviceKeyProtectionPolicy { - match protection_policy - .unwrap_or(codex_app_server_protocol::DeviceKeyProtectionPolicy::HardwareOnly) - { - codex_app_server_protocol::DeviceKeyProtectionPolicy::HardwareOnly => { - DeviceKeyProtectionPolicy::HardwareOnly - } - codex_app_server_protocol::DeviceKeyProtectionPolicy::AllowOsProtectedNonextractable => { - DeviceKeyProtectionPolicy::AllowOsProtectedNonextractable - } - } -} - -fn payload_from_params(payload: DeviceKeySignPayload) -> codex_device_key::DeviceKeySignPayload { - match payload { - DeviceKeySignPayload::RemoteControlClientConnection { - nonce, - audience, - session_id, - target_origin, - target_path, - account_user_id, - client_id, - token_sha256_base64url, - token_expires_at, - scopes, - } => codex_device_key::DeviceKeySignPayload::RemoteControlClientConnection( - RemoteControlClientConnectionSignPayload { - nonce, - audience: remote_control_client_connection_audience_from_protocol(audience), - session_id, - target_origin, - target_path, - account_user_id, - client_id, - token_sha256_base64url, - token_expires_at, - scopes, - }, - ), - DeviceKeySignPayload::RemoteControlClientEnrollment { - nonce, - audience, - challenge_id, - target_origin, - target_path, - account_user_id, - client_id, - device_identity_sha256_base64url, - challenge_expires_at, - } => codex_device_key::DeviceKeySignPayload::RemoteControlClientEnrollment( - RemoteControlClientEnrollmentSignPayload { - nonce, - audience: remote_control_client_enrollment_audience_from_protocol(audience), - challenge_id, - target_origin, - target_path, - account_user_id, - client_id, - device_identity_sha256_base64url, - challenge_expires_at, - }, - ), - } -} - -fn remote_control_client_connection_audience_from_protocol( - audience: codex_app_server_protocol::RemoteControlClientConnectionAudience, -) -> RemoteControlClientConnectionAudience { - match audience { - codex_app_server_protocol::RemoteControlClientConnectionAudience::RemoteControlClientWebsocket => { - RemoteControlClientConnectionAudience::RemoteControlClientWebsocket - } - } -} - -fn remote_control_client_enrollment_audience_from_protocol( - audience: codex_app_server_protocol::RemoteControlClientEnrollmentAudience, -) -> RemoteControlClientEnrollmentAudience { - match audience { - codex_app_server_protocol::RemoteControlClientEnrollmentAudience::RemoteControlClientEnrollment => { - RemoteControlClientEnrollmentAudience::RemoteControlClientEnrollment - } - } -} - -fn algorithm_from_store(algorithm: codex_device_key::DeviceKeyAlgorithm) -> DeviceKeyAlgorithm { - match algorithm { - codex_device_key::DeviceKeyAlgorithm::EcdsaP256Sha256 => { - DeviceKeyAlgorithm::EcdsaP256Sha256 - } - } -} - -fn protection_class_from_store( - protection_class: codex_device_key::DeviceKeyProtectionClass, -) -> DeviceKeyProtectionClass { - match protection_class { - codex_device_key::DeviceKeyProtectionClass::HardwareSecureEnclave => { - DeviceKeyProtectionClass::HardwareSecureEnclave - } - codex_device_key::DeviceKeyProtectionClass::HardwareTpm => { - DeviceKeyProtectionClass::HardwareTpm - } - codex_device_key::DeviceKeyProtectionClass::OsProtectedNonextractable => { - DeviceKeyProtectionClass::OsProtectedNonextractable - } - } -} - -fn map_device_key_error(error: DeviceKeyError) -> JSONRPCErrorError { - match &error { - DeviceKeyError::DegradedProtectionNotAllowed { .. } - | DeviceKeyError::HardwareBackedKeysUnavailable - | DeviceKeyError::KeyNotFound - | DeviceKeyError::InvalidPayload(_) => invalid_request(error.to_string()), - DeviceKeyError::Platform(_) | DeviceKeyError::Crypto(_) => { - internal_error(error.to_string()) - } - } -} diff --git a/codex-rs/app-server/src/error_code.rs b/codex-rs/app-server/src/error_code.rs index 0054d2988f..48e401f7bc 100644 --- a/codex-rs/app-server/src/error_code.rs +++ b/codex-rs/app-server/src/error_code.rs @@ -1,6 +1,7 @@ use codex_app_server_protocol::JSONRPCErrorError; pub(crate) const INVALID_REQUEST_ERROR_CODE: i64 = -32600; +pub(crate) const METHOD_NOT_FOUND_ERROR_CODE: i64 = -32601; pub const INVALID_PARAMS_ERROR_CODE: i64 = -32602; pub(crate) const INTERNAL_ERROR_CODE: i64 = -32603; pub(crate) const OVERLOADED_ERROR_CODE: i64 = -32001; @@ -10,6 +11,10 @@ pub(crate) fn invalid_request(message: impl Into) -> JSONRPCErrorError { error(INVALID_REQUEST_ERROR_CODE, message) } +pub(crate) fn method_not_found(message: impl Into) -> JSONRPCErrorError { + error(METHOD_NOT_FOUND_ERROR_CODE, message) +} + pub(crate) fn invalid_params(message: impl Into) -> JSONRPCErrorError { error(INVALID_PARAMS_ERROR_CODE, message) } diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs new file mode 100644 index 0000000000..411685e7d8 --- /dev/null +++ b/codex-rs/app-server/src/extensions.rs @@ -0,0 +1,41 @@ +use std::sync::Arc; +use std::sync::Weak; + +use codex_core::NewThread; +use codex_core::StartThreadOptions; +use codex_core::ThreadManager; +use codex_core::config::Config; +use codex_extension_api::AgentSpawnFuture; +use codex_extension_api::AgentSpawner; +use codex_extension_api::ExtensionRegistry; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_protocol::ThreadId; +use codex_protocol::error::CodexErr; + +pub(crate) fn thread_extensions(guardian_agent_spawner: S) -> Arc> +where + S: AgentSpawner + 'static, +{ + let mut builder = ExtensionRegistryBuilder::::new(); + codex_git_attribution::install(&mut builder); + codex_guardian::install(&mut builder, guardian_agent_spawner); + Arc::new(builder.build()) +} + +pub(crate) fn guardian_agent_spawner( + thread_manager: Weak, +) -> impl AgentSpawner { + move |forked_from_thread_id: ThreadId, + options: StartThreadOptions| + -> AgentSpawnFuture<'static, NewThread, CodexErr> { + let thread_manager = thread_manager.clone(); + Box::pin(async move { + let thread_manager = thread_manager.upgrade().ok_or_else(|| { + CodexErr::UnsupportedOperation("thread manager dropped".to_string()) + })?; + thread_manager + .spawn_subagent(forked_from_thread_id, options) + .await + }) + } +} diff --git a/codex-rs/app-server/src/fs_watch.rs b/codex-rs/app-server/src/fs_watch.rs index 47248451a2..09c6dc5533 100644 --- a/codex-rs/app-server/src/fs_watch.rs +++ b/codex-rs/app-server/src/fs_watch.rs @@ -8,12 +8,12 @@ use codex_app_server_protocol::FsWatchParams; use codex_app_server_protocol::FsWatchResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ServerNotification; -use codex_core::file_watcher::FileWatcher; -use codex_core::file_watcher::FileWatcherEvent; -use codex_core::file_watcher::FileWatcherSubscriber; -use codex_core::file_watcher::Receiver; -use codex_core::file_watcher::WatchPath; -use codex_core::file_watcher::WatchRegistration; +use codex_file_watcher::FileWatcher; +use codex_file_watcher::FileWatcherEvent; +use codex_file_watcher::FileWatcherSubscriber; +use codex_file_watcher::Receiver; +use codex_file_watcher::WatchPath; +use codex_file_watcher::WatchRegistration; use std::collections::HashMap; use std::collections::HashSet; use std::collections::hash_map::Entry; diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 0f7a31d6cb..29c0dae45a 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -52,9 +52,9 @@ use std::time::Duration; use crate::analytics_utils::analytics_events_client_from_config; use crate::config_manager::ConfigManager; -use crate::error_code::INTERNAL_ERROR_CODE; -use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::error_code::OVERLOADED_ERROR_CODE; +use crate::error_code::internal_error; +use crate::error_code::invalid_request; use crate::message_processor::ConnectionSessionState; use crate::message_processor::MessageProcessor; use crate::message_processor::MessageProcessorArgs; @@ -64,7 +64,6 @@ use crate::outgoing_message::OutgoingMessage; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::QueuedOutgoingMessage; use crate::transport::CHANNEL_CAPACITY; -use crate::transport::ConnectionOrigin; use crate::transport::OutboundConnectionState; use crate::transport::route_outgoing_envelope; use codex_analytics::AppServerRpcTransport; @@ -82,10 +81,12 @@ use codex_config::CloudRequirementsLoader; use codex_config::LoaderOverrides; use codex_config::ThreadConfigLoader; use codex_core::config::Config; +use codex_core::resolve_installation_id; use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; use codex_login::AuthManager; use codex_protocol::protocol::SessionSource; +pub use codex_rollout::StateDbHandle; pub use codex_state::log_db::LogDbLayer; use tokio::sync::mpsc; use tokio::sync::oneshot; @@ -126,6 +127,8 @@ pub struct InProcessStartArgs { pub feedback: CodexFeedback, /// SQLite tracing layer used to flush recently emitted logs before feedback upload. pub log_db: Option, + /// Process-wide SQLite state handle shared with embedded app-server consumers. + pub state_db: Option, /// Environment manager used by core execution and filesystem operations. pub environment_manager: Arc, /// Startup warnings emitted after initialize succeeds. @@ -251,6 +254,8 @@ pub struct InProcessClientHandle { client: InProcessClientSender, event_rx: mpsc::Receiver, runtime_handle: tokio::task::JoinHandle<()>, + #[cfg(test)] + _test_codex_home: Option, } impl InProcessClientHandle { @@ -339,7 +344,7 @@ impl InProcessClientHandle { /// the runtime is shut down and an `InvalidData` error is returned. pub async fn start(args: InProcessStartArgs) -> IoResult { let initialize = args.initialize.clone(); - let client = start_uninitialized(args); + let client = start_uninitialized(args).await?; let initialize_response = client .request(ClientRequest::Initialize { @@ -359,8 +364,9 @@ pub async fn start(args: InProcessStartArgs) -> IoResult Ok(client) } -fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { +async fn start_uninitialized(args: InProcessStartArgs) -> IoResult { let channel_capacity = args.channel_capacity.max(1); + let installation_id = resolve_installation_id(&args.config.codex_home).await?; let (client_tx, mut client_rx) = mpsc::channel::(channel_capacity); let (event_tx, event_rx) = mpsc::channel::(channel_capacity); @@ -418,15 +424,16 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { environment_manager: args.environment_manager, feedback: args.feedback, log_db: args.log_db, + state_db: args.state_db, config_warnings: args.config_warnings, session_source: args.session_source, auth_manager, + installation_id, rpc_transport: AppServerRpcTransport::InProcess, - remote_control_handle: None, plugin_startup_tasks: crate::PluginStartupTasks::Start, })); let mut thread_created_rx = processor.thread_created_receiver(); - let session = Arc::new(ConnectionSessionState::new(ConnectionOrigin::InProcess)); + let session = Arc::new(ConnectionSessionState::new()); let mut listen_for_threads = true; loop { @@ -520,11 +527,9 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { entry.insert(response_tx); } Entry::Occupied(_) => { - let _ = response_tx.send(Err(JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("duplicate request id: {request_id:?}"), - data: None, - })); + let _ = response_tx.send(Err(invalid_request(format!( + "duplicate request id: {request_id:?}" + )))); continue; } } @@ -547,13 +552,9 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { if let Some(response_tx) = pending_request_responses.remove(&request_id) { - let _ = response_tx.send(Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: - "in-process app-server request processor is closed" - .to_string(), - data: None, - })); + let _ = response_tx.send(Err(internal_error( + "in-process app-server request processor is closed", + ))); } break; } @@ -621,15 +622,20 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { if let Err(send_error) = event_tx .try_send(InProcessServerEvent::ServerRequest(request)) { - let (code, message, inner) = match send_error { + let (error, inner) = match send_error { mpsc::error::TrySendError::Full(inner) => ( - OVERLOADED_ERROR_CODE, - "in-process server request queue is full", + JSONRPCErrorError { + code: OVERLOADED_ERROR_CODE, + message: + "in-process server request queue is full".to_string(), + data: None, + }, inner, ), mpsc::error::TrySendError::Closed(inner) => ( - INTERNAL_ERROR_CODE, - "in-process server request consumer is closed", + internal_error( + "in-process server request consumer is closed", + ), inner, ), }; @@ -638,14 +644,7 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { _ => unreachable!("we just sent a ServerRequest variant"), }; outgoing_message_sender - .notify_client_error( - request_id, - JSONRPCErrorError { - code, - message: message.to_string(), - data: None, - }, - ) + .notify_client_error(request_id, error) .await; } } @@ -682,21 +681,17 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { drop(writer_rx); drop(processor_tx); outgoing_message_sender - .cancel_all_requests(Some(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: "in-process app-server runtime is shutting down".to_string(), - data: None, - })) + .cancel_all_requests(Some(internal_error( + "in-process app-server runtime is shutting down", + ))) .await; // Drop the runtime's last sender before awaiting the router task so // `outgoing_rx.recv()` can observe channel closure and exit cleanly. drop(outgoing_message_sender); for (_, response_tx) in pending_request_responses { - let _ = response_tx.send(Err(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: "in-process app-server runtime is shutting down".to_string(), - data: None, - })); + let _ = response_tx.send(Err(internal_error( + "in-process app-server runtime is shutting down", + ))); } if let Err(_elapsed) = timeout(SHUTDOWN_TIMEOUT, &mut processor_handle).await { @@ -713,11 +708,13 @@ fn start_uninitialized(args: InProcessStartArgs) -> InProcessClientHandle { } }); - InProcessClientHandle { + Ok(InProcessClientHandle { client: InProcessClientSender { client_tx }, event_rx, runtime_handle, - } + #[cfg(test)] + _test_codex_home: None, + }) } #[cfg(test)] @@ -725,26 +722,31 @@ mod tests { use super::*; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ConfigRequirementsReadResponse; - use codex_app_server_protocol::DeviceKeyPublicParams; - use codex_app_server_protocol::DeviceKeySignParams; - use codex_app_server_protocol::DeviceKeySignPayload; - use codex_app_server_protocol::RemoteControlClientConnectionAudience; - use codex_app_server_protocol::RemoteControlClientEnrollmentAudience; use codex_app_server_protocol::SessionSource as ApiSessionSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnCompletedNotification; + use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStatus; use codex_core::config::ConfigBuilder; use pretty_assertions::assert_eq; + use std::path::Path; + use tempfile::TempDir; - async fn build_test_config() -> Config { - match ConfigBuilder::default().build().await { + async fn build_test_config(codex_home: &Path) -> Config { + match ConfigBuilder::default() + .codex_home(codex_home.to_path_buf()) + .build() + .await + { Ok(config) => config, - Err(_) => Config::load_default_with_cli_overrides(Vec::new()) - .await - .expect("default config should load"), + Err(_) => Config::load_default_with_cli_overrides_for_codex_home( + codex_home.to_path_buf(), + Vec::new(), + ) + .await + .expect("default config should load"), } } @@ -752,15 +754,21 @@ mod tests { session_source: SessionSource, channel_capacity: usize, ) -> InProcessClientHandle { + let codex_home = TempDir::new().expect("temp dir"); + let config = Arc::new(build_test_config(codex_home.path()).await); + let state_db = codex_rollout::state_db::try_init(config.as_ref()) + .await + .expect("state db should initialize for in-process test"); let args = InProcessStartArgs { arg0_paths: Arg0DispatchPaths::default(), - config: Arc::new(build_test_config().await), + config, cli_overrides: Vec::new(), loader_overrides: LoaderOverrides::default(), cloud_requirements: CloudRequirementsLoader::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, + state_db: Some(state_db), environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source, @@ -775,7 +783,9 @@ mod tests { }, channel_capacity, }; - start(args).await.expect("in-process runtime should start") + let mut client = start(args).await.expect("in-process runtime should start"); + client._test_codex_home = Some(codex_home); + client } async fn start_test_client(session_source: SessionSource) -> InProcessClientHandle { @@ -803,87 +813,6 @@ mod tests { .expect("in-process runtime should shutdown cleanly"); } - #[tokio::test] - async fn in_process_allows_device_key_requests_to_reach_device_key_api() { - let client = start_test_client(SessionSource::Cli).await; - const MALFORMED_KEY_ID_MESSAGE: &str = concat!( - "invalid device key payload: keyId must be dk_hse_, dk_tpm_, or dk_osn_ ", - "followed by unpadded base64url-encoded 32 bytes" - ); - let requests = [ - ( - ClientRequest::DeviceKeyPublic { - request_id: RequestId::Integer(11), - params: DeviceKeyPublicParams { - key_id: String::new(), - }, - }, - MALFORMED_KEY_ID_MESSAGE, - ), - ( - ClientRequest::DeviceKeySign { - request_id: RequestId::Integer(12), - params: DeviceKeySignParams { - key_id: String::new(), - payload: DeviceKeySignPayload::RemoteControlClientConnection { - nonce: "nonce-123".to_string(), - audience: - RemoteControlClientConnectionAudience::RemoteControlClientWebsocket, - session_id: "wssess_123".to_string(), - target_origin: "https://chatgpt.com".to_string(), - target_path: "/api/codex/remote/control/client".to_string(), - account_user_id: "acct_123".to_string(), - client_id: "cli_123".to_string(), - token_expires_at: 4_102_444_800, - token_sha256_base64url: "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU" - .to_string(), - scopes: vec!["remote_control_controller_websocket".to_string()], - }, - }, - }, - MALFORMED_KEY_ID_MESSAGE, - ), - ( - ClientRequest::DeviceKeySign { - request_id: RequestId::Integer(13), - params: DeviceKeySignParams { - key_id: String::new(), - payload: DeviceKeySignPayload::RemoteControlClientEnrollment { - nonce: "nonce-123".to_string(), - audience: - RemoteControlClientEnrollmentAudience::RemoteControlClientEnrollment, - challenge_id: "rch_123".to_string(), - target_origin: "https://chatgpt.com".to_string(), - target_path: "/wham/remote/control/client/enroll".to_string(), - account_user_id: "acct_123".to_string(), - client_id: "cli_123".to_string(), - device_identity_sha256_base64url: - "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU".to_string(), - challenge_expires_at: 4_102_444_800, - }, - }, - }, - MALFORMED_KEY_ID_MESSAGE, - ), - ]; - - for (request, expected_message) in requests { - let error = client - .request(request) - .await - .expect("request transport should work") - .expect_err("request should be rejected"); - - assert_eq!(error.code, INVALID_REQUEST_ERROR_CODE); - assert_eq!(error.message, expected_message); - } - - client - .shutdown() - .await - .expect("in-process runtime should shutdown cleanly"); - } - #[tokio::test] async fn in_process_start_uses_requested_session_source_for_thread_start() { for (requested_source, expected_source) in [ @@ -947,6 +876,7 @@ mod tests { turn: Turn { id: "turn-1".to_string(), items: Vec::new(), + items_view: TurnItemsView::NotLoaded, status: TurnStatus::Completed, error: None, started_at: None, diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 4df869551e..baff7c8d49 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -7,8 +7,7 @@ use codex_config::NoopThreadConfigLoader; use codex_config::RemoteThreadConfigLoader; use codex_config::ThreadConfigLoader; use codex_core::config::Config; -use codex_exec_server::EnvironmentManagerArgs; -use codex_features::Feature; +use codex_core::resolve_installation_id; use codex_login::AuthManager; use codex_utils_cli::CliConfigOverrides; use std::collections::HashMap; @@ -30,6 +29,7 @@ use crate::outgoing_message::QueuedOutgoingMessage; use crate::transport::CHANNEL_CAPACITY; use crate::transport::ConnectionState; use crate::transport::OutboundConnectionState; +use crate::transport::RemoteControlStartConfig; use crate::transport::TransportEvent; use crate::transport::auth::policy_from_settings; use crate::transport::route_outgoing_envelope; @@ -54,6 +54,7 @@ use codex_exec_server::EnvironmentManager; use codex_exec_server::ExecServerRuntimePaths; use codex_feedback::CodexFeedback; use codex_protocol::protocol::SessionSource; +use codex_rollout::state_db as rollout_state_db; use codex_state::log_db; use tokio::sync::mpsc; use tokio::sync::oneshot; @@ -72,28 +73,28 @@ use tracing_subscriber::util::SubscriberInitExt; mod analytics_utils; mod app_server_tracing; +mod attestation; mod bespoke_event_handling; -mod codex_message_processor; mod command_exec; mod config; -mod config_api; mod config_manager; mod config_manager_service; mod connection_rpc_gate; -mod device_key_api; mod dynamic_tools; mod error_code; -mod external_agent_config_api; +mod extensions; mod filters; -mod fs_api; mod fs_watch; mod fuzzy_file_search; pub mod in_process; +mod mcp_refresh; mod message_processor; mod models; mod outgoing_message; +mod request_processors; mod request_serialization; mod server_request_error; +mod skills_watcher; mod thread_state; mod thread_status; mod transport; @@ -107,6 +108,7 @@ pub use crate::transport::auth::AppServerWebsocketAuthSettings; pub use crate::transport::auth::WebsocketAuthCliMode; const LOG_FORMAT_ENV_VAR: &str = "LOG_FORMAT"; +const OTEL_SERVICE_NAME: &str = "codex-app-server"; #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum LogFormat { @@ -160,22 +162,33 @@ enum ShutdownAction { Finish, } -async fn shutdown_signal() -> IoResult<()> { +#[derive(Clone, Copy)] +enum ShutdownSignal { + Forceable, + #[cfg(unix)] + GracefulOnly, +} + +async fn shutdown_signal() -> IoResult { #[cfg(unix)] { use tokio::signal::unix::SignalKind; use tokio::signal::unix::signal; let mut term = signal(SignalKind::terminate())?; + let mut hangup = signal(SignalKind::hangup())?; tokio::select! { - ctrl_c_result = tokio::signal::ctrl_c() => ctrl_c_result, - _ = term.recv() => Ok(()), + ctrl_c_result = tokio::signal::ctrl_c() => ctrl_c_result.map(|_| ShutdownSignal::Forceable), + _ = term.recv() => Ok(ShutdownSignal::Forceable), + _ = hangup.recv() => Ok(ShutdownSignal::GracefulOnly), } } #[cfg(not(unix))] { - tokio::signal::ctrl_c().await + tokio::signal::ctrl_c() + .await + .map(|_| ShutdownSignal::Forceable) } } @@ -188,9 +201,16 @@ impl ShutdownState { self.forced } - fn on_signal(&mut self, connection_count: usize, running_turn_count: usize) { + fn on_signal( + &mut self, + signal: ShutdownSignal, + connection_count: usize, + running_turn_count: usize, + ) { if self.requested { - self.forced = true; + if matches!(signal, ShutdownSignal::Forceable) { + self.forced = true; + } return; } @@ -377,12 +397,14 @@ pub enum PluginStartupTasks { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct AppServerRuntimeOptions { pub plugin_startup_tasks: PluginStartupTasks, + pub remote_control_enabled: bool, } impl Default for AppServerRuntimeOptions { fn default() -> Self { Self { plugin_startup_tasks: PluginStartupTasks::Start, + remote_control_enabled: false, } } } @@ -420,15 +442,6 @@ pub async fn run_main_with_transport_options( auth: AppServerWebsocketAuthSettings, runtime_options: AppServerRuntimeOptions, ) -> IoResult<()> { - let environment_manager = Arc::new( - EnvironmentManager::new(EnvironmentManagerArgs::new( - ExecServerRuntimePaths::from_optional_paths( - arg0_paths.codex_self_exe.clone(), - arg0_paths.codex_linux_sandbox_exe.clone(), - )?, - )) - .await, - ); let (transport_event_tx, mut transport_event_rx) = mpsc::channel::(CHANNEL_CAPACITY); let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(CHANNEL_CAPACITY); @@ -444,6 +457,17 @@ pub async fn run_main_with_transport_options( ) })?; let codex_home = find_codex_home()?; + let local_runtime_paths = ExecServerRuntimePaths::from_optional_paths( + arg0_paths.codex_self_exe.clone(), + arg0_paths.codex_linux_sandbox_exe.clone(), + )?; + let environment_manager = if loader_overrides.ignore_user_config { + EnvironmentManager::from_env(local_runtime_paths).await + } else { + EnvironmentManager::from_codex_home(codex_home.clone(), local_runtime_paths).await + } + .map(Arc::new) + .map_err(std::io::Error::other)?; let config_manager = ConfigManager::new( codex_home.to_path_buf(), cli_kv_overrides.clone(), @@ -457,23 +481,6 @@ pub async fn run_main_with_transport_options( .await { Ok(config) => { - let effective_toml = config.config_layer_stack.effective_config(); - match effective_toml.try_into() { - Ok(config_toml) => { - if let Err(err) = codex_core::personality_migration::maybe_migrate_personality( - &config.codex_home, - &config_toml, - ) - .await - { - warn!(error = %err, "Failed to run personality migration"); - } - } - Err(err) => { - warn!(error = %err, "Failed to deserialize config for personality migration"); - } - } - let discovered_thread_config_loader = configured_thread_config_loader(&config); config_manager .replace_thread_config_loader(Arc::clone(&discovered_thread_config_loader)); @@ -487,23 +494,84 @@ pub async fn run_main_with_transport_options( } }; let mut config_warnings = Vec::new(); - let config = match config_manager + let (mut config, should_run_personality_migration) = match config_manager .load_latest_config(/*fallback_cwd*/ None) .await { - Ok(config) => config, + Ok(config) => (config, true), Err(err) => { let message = config_warning_from_error("Invalid configuration; using defaults.", &err); config_warnings.push(message); - config_manager.load_default_config().await.map_err(|e| { - std::io::Error::new( - ErrorKind::InvalidData, - format!("error loading default config after config error: {e}"), - ) - })? + ( + config_manager.load_default_config().await.map_err(|e| { + std::io::Error::new( + ErrorKind::InvalidData, + format!("error loading default config after config error: {e}"), + ) + })?, + false, + ) } }; + let otel = codex_core::otel_init::build_provider( + &config, + env!("CARGO_PKG_VERSION"), + Some(OTEL_SERVICE_NAME), + default_analytics_enabled, + ) + .map_err(|e| { + std::io::Error::new( + ErrorKind::InvalidData, + format!("error loading otel config: {e}"), + ) + })?; + codex_core::otel_init::record_process_start(otel.as_ref(), OTEL_SERVICE_NAME); + codex_core::otel_init::install_sqlite_telemetry(otel.as_ref(), OTEL_SERVICE_NAME); + let state_db_result = rollout_state_db::try_init(&config).await; + let state_db_init_error = state_db_result.as_ref().err().map(ToString::to_string); + let state_db = state_db_result.ok(); + + if should_run_personality_migration { + let effective_toml = config.config_layer_stack.effective_config(); + match effective_toml.try_into() { + Ok(config_toml) => { + match codex_core::personality_migration::maybe_migrate_personality( + &config.codex_home, + &config_toml, + state_db.clone(), + ) + .await + { + Ok(codex_core::personality_migration::PersonalityMigrationStatus::Applied) => { + config = config_manager + .load_latest_config(/*fallback_cwd*/ None) + .await + .map_err(|err| { + std::io::Error::new( + ErrorKind::InvalidData, + format!( + "error reloading config after personality migration: {err}" + ), + ) + })?; + } + Ok( + codex_core::personality_migration::PersonalityMigrationStatus::SkippedMarker + | codex_core::personality_migration::PersonalityMigrationStatus::SkippedExplicitPersonality + | codex_core::personality_migration::PersonalityMigrationStatus::SkippedNoSessions, + ) => {} + Err(err) => { + warn!(error = %err, "Failed to run personality migration"); + } + } + } + Err(err) => { + warn!(error = %err, "Failed to deserialize config for personality migration"); + } + } + } + if let Ok(Some(err)) = check_execpolicy_for_warnings(&config.config_layer_stack).await { let (path, range) = exec_policy_warning_location(&err); let message = ConfigWarningNotification { @@ -539,19 +607,6 @@ pub async fn run_main_with_transport_options( let feedback = CodexFeedback::new(); - let otel = codex_core::otel_init::build_provider( - &config, - env!("CARGO_PKG_VERSION"), - Some("codex-app-server"), - default_analytics_enabled, - ) - .map_err(|e| { - std::io::Error::new( - ErrorKind::InvalidData, - format!("error loading otel config: {e}"), - ) - })?; - // Install a simple subscriber so `tracing` output is visible. Users can // control the log level with `RUST_LOG` and switch to JSON logs with // `LOG_FORMAT=json`. @@ -571,13 +626,6 @@ pub async fn run_main_with_transport_options( let feedback_layer = feedback.logger_layer(); let feedback_metadata_layer = feedback.metadata_layer(); - let state_db_result = codex_state::StateRuntime::init( - config.sqlite_home.clone(), - config.model_provider_id.clone(), - ) - .await; - let state_db_init_error = state_db_result.as_ref().err().map(ToString::to_string); - let state_db = state_db_result.ok(); let log_db = state_db.clone().map(log_db::start); let log_db_layer = log_db .clone() @@ -598,6 +646,7 @@ pub async fn run_main_with_transport_options( None => error!("{}", warning.summary), } } + let installation_id = resolve_installation_id(&config.codex_home).await?; if let Some(err) = &state_db_init_error { error!("failed to initialize sqlite state db: {err}"); } @@ -646,15 +695,15 @@ pub async fn run_main_with_transport_options( let auth_manager = AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await; - let remote_control_config_enabled = config.features.enabled(Feature::RemoteControl); - let remote_control_enabled = remote_control_config_enabled && state_db.is_some(); - if remote_control_config_enabled && state_db.is_none() { + let remote_control_requested = runtime_options.remote_control_enabled; + let remote_control_enabled = remote_control_requested && state_db.is_some(); + if remote_control_requested && state_db.is_none() { error!("remote control disabled because sqlite state db is unavailable"); } if transport_accept_handles.is_empty() && !remote_control_enabled { return Err(std::io::Error::new( ErrorKind::InvalidInput, - if remote_control_config_enabled && state_db.is_none() { + if remote_control_requested && state_db.is_none() { "no transport configured; remote control disabled because sqlite state db is unavailable" } else { "no transport configured; use --listen or enable remote control" @@ -663,7 +712,10 @@ pub async fn run_main_with_transport_options( } let (remote_control_accept_handle, remote_control_handle) = start_remote_control( - config.chatgpt_base_url.clone(), + RemoteControlStartConfig { + remote_control_url: config.chatgpt_base_url.clone(), + installation_id: installation_id.clone(), + }, state_db.clone(), auth_manager.clone(), transport_event_tx.clone(), @@ -749,11 +801,12 @@ pub async fn run_main_with_transport_options( environment_manager, feedback: feedback.clone(), log_db, + state_db: state_db.clone(), config_warnings, session_source, auth_manager, + installation_id, rpc_transport: analytics_rpc_transport(&transport), - remote_control_handle: Some(remote_control_handle.clone()), plugin_startup_tasks: runtime_options.plugin_startup_tasks, })); let mut thread_created_rx = processor.thread_created_receiver(); @@ -783,11 +836,15 @@ pub async fn run_main_with_transport_options( tokio::select! { shutdown_signal_result = shutdown_signal(), if graceful_signal_restart_enabled && !shutdown_state.forced() => { - if let Err(err) = shutdown_signal_result { - warn!("failed to listen for shutdown signal during graceful restart drain: {err}"); - } + let signal = match shutdown_signal_result { + Ok(signal) => signal, + Err(err) => { + warn!("failed to listen for shutdown signal during graceful restart drain: {err}"); + continue; + } + }; let running_turn_count = *running_turn_count_rx.borrow(); - shutdown_state.on_signal(connections.len(), running_turn_count); + shutdown_state.on_signal(signal, connections.len(), running_turn_count); } changed = running_turn_count_rx.changed(), if graceful_signal_restart_enabled && shutdown_state.requested() => { if changed.is_err() { @@ -908,7 +965,14 @@ pub async fn run_main_with_transport_options( ), ) .await; - processor.connection_initialized(connection_id).await; + processor + .connection_initialized( + connection_id, + connection_state + .session + .request_attestation(), + ) + .await; connection_state .outbound_initialized .store(true, std::sync::atomic::Ordering::Release); @@ -952,6 +1016,7 @@ pub async fn run_main_with_transport_options( .send_server_notification(ServerNotification::RemoteControlStatusChanged( RemoteControlStatusChangedNotification { status: status.status, + installation_id: status.installation_id, environment_id: status.environment_id, }, )) diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 1cb4bd9a8e..31a017d17c 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -44,6 +44,10 @@ struct AppServerArgs { #[cfg(debug_assertions)] #[arg(long = "disable-plugin-startup-tasks-for-tests", hide = true)] disable_plugin_startup_tasks_for_tests: bool, + + /// Enable remote control for this app-server process. + #[arg(long = "remote-control", hide = true)] + remote_control: bool, } fn main() -> anyhow::Result<()> { @@ -64,6 +68,7 @@ fn main() -> anyhow::Result<()> { if args.disable_plugin_startup_tasks_for_tests { runtime_options.plugin_startup_tasks = PluginStartupTasks::Skip; } + runtime_options.remote_control_enabled = args.remote_control; run_main_with_transport_options( arg0_paths, diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs new file mode 100644 index 0000000000..31a6f4afd8 --- /dev/null +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -0,0 +1,245 @@ +use crate::config_manager::ConfigManager; +use codex_core::CodexThread; +use codex_core::ThreadManager; +use codex_core::config::Config; +use codex_protocol::ThreadId; +use codex_protocol::protocol::McpServerRefreshConfig; +use codex_protocol::protocol::Op; +use std::io; +use std::sync::Arc; +use tracing::warn; + +pub(crate) async fn queue_strict_refresh( + thread_manager: &Arc, + config_manager: &ConfigManager, +) -> io::Result<()> { + config_manager + .load_latest_config(/*fallback_cwd*/ None) + .await?; + let mut refreshes = Vec::new(); + for thread_id in thread_manager.list_thread_ids().await { + let thread = thread_manager + .get_thread(thread_id) + .await + .map_err(|err| io::Error::other(format!("failed to load thread {thread_id}: {err}")))?; + let config = + build_refresh_config(thread_manager, config_manager, thread.config().await).await?; + refreshes.push((thread_id, thread, config)); + } + for (thread_id, thread, config) in refreshes { + queue_refresh(thread_id, thread, config).await?; + } + Ok(()) +} + +pub(crate) async fn queue_best_effort_refresh( + thread_manager: &Arc, + config_manager: &ConfigManager, +) { + for thread_id in thread_manager.list_thread_ids().await { + let thread = match thread_manager.get_thread(thread_id).await { + Ok(thread) => thread, + Err(err) => { + warn!("failed to load thread {thread_id} for MCP refresh: {err}"); + continue; + } + }; + let config = + match build_refresh_config(thread_manager, config_manager, thread.config().await).await + { + Ok(config) => config, + Err(err) => { + warn!("failed to build MCP refresh config for thread {thread_id}: {err}"); + continue; + } + }; + if let Err(err) = queue_refresh(thread_id, thread, config).await { + warn!("{err}"); + } + } +} + +async fn build_refresh_config( + thread_manager: &ThreadManager, + config_manager: &ConfigManager, + thread_config: Arc, +) -> io::Result { + let config = config_manager + .load_latest_config_for_thread(thread_config.as_ref()) + .await?; + let mcp_servers = thread_manager + .mcp_manager() + .configured_servers(&config) + .await; + Ok(McpServerRefreshConfig { + mcp_servers: serde_json::to_value(mcp_servers).map_err(io::Error::other)?, + mcp_oauth_credentials_store_mode: serde_json::to_value( + config.mcp_oauth_credentials_store_mode, + ) + .map_err(io::Error::other)?, + }) +} + +async fn queue_refresh( + thread_id: ThreadId, + thread: Arc, + config: McpServerRefreshConfig, +) -> io::Result<()> { + thread + .submit(Op::RefreshMcpServers { config }) + .await + .map(|_| ()) + .map_err(|err| { + io::Error::other(format!( + "failed to queue MCP refresh for thread {thread_id}: {err}" + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::extensions::guardian_agent_spawner; + use crate::extensions::thread_extensions; + use async_trait::async_trait; + use codex_arg0::Arg0DispatchPaths; + use codex_config::CloudRequirementsLoader; + use codex_config::LoaderOverrides; + use codex_config::ThreadConfigContext; + use codex_config::ThreadConfigLoadError; + use codex_config::ThreadConfigLoadErrorCode; + use codex_config::ThreadConfigLoader; + use codex_config::ThreadConfigSource; + use codex_core::config::ConfigOverrides; + use codex_core::init_state_db; + use codex_core::thread_store_from_config; + use codex_exec_server::EnvironmentManager; + use codex_login::AuthManager; + use codex_login::CodexAuth; + use codex_protocol::protocol::SessionSource; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + use std::sync::atomic::AtomicUsize; + use std::sync::atomic::Ordering; + use tempfile::TempDir; + + #[tokio::test] + async fn strict_refresh_reports_thread_planning_failures() -> anyhow::Result<()> { + let (_temp_dir, thread_manager, config_manager, _loader) = refresh_test_state().await?; + + let err = queue_strict_refresh(&thread_manager, &config_manager) + .await + .expect_err("strict refresh should fail"); + + assert_eq!(err.to_string(), "failed to load refresh config"); + Ok(()) + } + + #[tokio::test] + async fn best_effort_refresh_attempts_every_loaded_thread() -> anyhow::Result<()> { + let (_temp_dir, thread_manager, config_manager, loader) = refresh_test_state().await?; + + queue_best_effort_refresh(&thread_manager, &config_manager).await; + + assert_eq!(loader.good_loads.load(Ordering::Relaxed), 1); + assert_eq!(loader.bad_loads.load(Ordering::Relaxed), 1); + Ok(()) + } + + async fn refresh_test_state() -> anyhow::Result<( + TempDir, + Arc, + ConfigManager, + Arc, + )> { + let temp_dir = TempDir::new()?; + let good_cwd = temp_dir.path().join("good"); + let bad_cwd = temp_dir.path().join("bad"); + std::fs::create_dir_all(&good_cwd)?; + std::fs::create_dir_all(&bad_cwd)?; + + let initial_config_manager = + ConfigManager::without_managed_config_for_tests(temp_dir.path().to_path_buf()); + let good_config = initial_config_manager + .load_for_cwd( + /*request_overrides*/ None, + ConfigOverrides::default(), + Some(good_cwd.clone()), + ) + .await?; + let bad_config = initial_config_manager + .load_for_cwd( + /*request_overrides*/ None, + ConfigOverrides::default(), + Some(bad_cwd.clone()), + ) + .await?; + + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")); + let state_db = init_state_db(&good_config) + .await + .expect("refresh tests require state db"); + let thread_store = thread_store_from_config(&good_config, Some(state_db.clone())); + let thread_manager = Arc::new_cyclic(|thread_manager| { + ThreadManager::new( + &good_config, + auth_manager, + SessionSource::Exec, + Arc::new(EnvironmentManager::default_for_tests()), + thread_extensions(guardian_agent_spawner(thread_manager.clone())), + /*analytics_events_client*/ None, + thread_store, + Some(state_db.clone()), + "11111111-1111-4111-8111-111111111111".to_string(), + /*attestation_provider*/ None, + ) + }); + thread_manager.start_thread(good_config).await?; + thread_manager.start_thread(bad_config).await?; + + let loader = Arc::new(CountingThreadConfigLoader { + good_cwd: AbsolutePathBuf::try_from(good_cwd)?, + bad_cwd: AbsolutePathBuf::try_from(bad_cwd)?, + good_loads: AtomicUsize::new(0), + bad_loads: AtomicUsize::new(0), + }); + let config_manager = ConfigManager::new( + temp_dir.path().to_path_buf(), + Vec::new(), + LoaderOverrides::without_managed_config_for_tests(), + CloudRequirementsLoader::default(), + Arg0DispatchPaths::default(), + loader.clone(), + ); + + Ok((temp_dir, thread_manager, config_manager, loader)) + } + + struct CountingThreadConfigLoader { + good_cwd: AbsolutePathBuf, + bad_cwd: AbsolutePathBuf, + good_loads: AtomicUsize, + bad_loads: AtomicUsize, + } + + #[async_trait] + impl ThreadConfigLoader for CountingThreadConfigLoader { + async fn load( + &self, + context: ThreadConfigContext, + ) -> Result, ThreadConfigLoadError> { + if context.cwd.as_ref() == Some(&self.good_cwd) { + self.good_loads.fetch_add(1, Ordering::Relaxed); + } + if context.cwd.as_ref() == Some(&self.bad_cwd) { + self.bad_loads.fetch_add(1, Ordering::Relaxed); + return Err(ThreadConfigLoadError::new( + ThreadConfigLoadErrorCode::Internal, + /*status_code*/ None, + "failed to load refresh config", + )); + } + Ok(Vec::new()) + } + } +} diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 7b394c3d8c..d4ad42ffc6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -3,88 +3,82 @@ use std::future::Future; use std::sync::Arc; use std::sync::OnceLock; use std::sync::atomic::AtomicBool; -use std::sync::atomic::Ordering; -use crate::codex_message_processor::CodexMessageProcessor; -use crate::codex_message_processor::CodexMessageProcessorArgs; -use crate::config_api::ConfigApi; +use crate::attestation::app_server_attestation_provider; use crate::config_manager::ConfigManager; use crate::connection_rpc_gate::ConnectionRpcGate; -use crate::device_key_api::DeviceKeyApi; use crate::error_code::invalid_request; -use crate::external_agent_config_api::ExternalAgentConfigApi; -use crate::fs_api::FsApi; +use crate::extensions::guardian_agent_spawner; +use crate::extensions::thread_extensions; use crate::fs_watch::FsWatchManager; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::RequestContext; +use crate::request_processors::AccountRequestProcessor; +use crate::request_processors::AppsRequestProcessor; +use crate::request_processors::CatalogRequestProcessor; +use crate::request_processors::CommandExecRequestProcessor; +use crate::request_processors::ConfigRequestProcessor; +use crate::request_processors::EnvironmentRequestProcessor; +use crate::request_processors::ExternalAgentConfigRequestProcessor; +use crate::request_processors::FeedbackRequestProcessor; +use crate::request_processors::FsRequestProcessor; +use crate::request_processors::GitRequestProcessor; +use crate::request_processors::InitializeRequestProcessor; +use crate::request_processors::MarketplaceRequestProcessor; +use crate::request_processors::McpRequestProcessor; +use crate::request_processors::PluginRequestProcessor; +use crate::request_processors::ProcessExecRequestProcessor; +use crate::request_processors::SearchRequestProcessor; +use crate::request_processors::ThreadGoalRequestProcessor; +use crate::request_processors::ThreadRequestProcessor; +use crate::request_processors::TurnRequestProcessor; +use crate::request_processors::WindowsSandboxRequestProcessor; use crate::request_serialization::QueuedInitializedRequest; use crate::request_serialization::RequestSerializationQueueKey; use crate::request_serialization::RequestSerializationQueues; +use crate::skills_watcher::SkillsWatcher; +use crate::thread_state::ConnectionCapabilities; +use crate::thread_state::ThreadStateManager; use crate::transport::AppServerTransport; -use crate::transport::ConnectionOrigin; -use crate::transport::RemoteControlHandle; use async_trait::async_trait; -use axum::http::HeaderValue; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppServerRpcTransport; -use codex_app_server_protocol::AppListUpdatedNotification; use codex_app_server_protocol::AuthMode as LoginAuthMode; use codex_app_server_protocol::ChatgptAuthTokensRefreshParams; use codex_app_server_protocol::ChatgptAuthTokensRefreshReason; use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse; -use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponsePayload; -use codex_app_server_protocol::ConfigBatchWriteParams; -use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWarningNotification; -use codex_app_server_protocol::DeviceKeyCreateParams; -use codex_app_server_protocol::DeviceKeyPublicParams; -use codex_app_server_protocol::DeviceKeySignParams; use codex_app_server_protocol::ExperimentalApi; -use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; -use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification; -use codex_app_server_protocol::ExternalAgentConfigImportParams; -use codex_app_server_protocol::ExternalAgentConfigImportResponse; -use codex_app_server_protocol::ExternalAgentConfigMigrationItem; -use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; -use codex_app_server_protocol::InitializeResponse; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::ModelProviderCapabilitiesReadResponse; -use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::experimental_required_message; use codex_arg0::Arg0DispatchPaths; -use codex_chatgpt::connectors; +use codex_chatgpt::workspace_settings; use codex_core::ThreadManager; use codex_core::config::Config; -use codex_core::thread_store_from_config; use codex_exec_server::EnvironmentManager; -use codex_features::Feature; use codex_feedback::CodexFeedback; use codex_login::AuthManager; use codex_login::auth::ExternalAuth; use codex_login::auth::ExternalAuthRefreshContext; use codex_login::auth::ExternalAuthRefreshReason; use codex_login::auth::ExternalAuthTokens; -use codex_login::default_client::SetOriginatorError; -use codex_login::default_client::USER_AGENT_SUFFIX; -use codex_login::default_client::get_codex_user_agent; -use codex_login::default_client::set_default_client_residency_requirement; -use codex_login::default_client::set_default_originator; -use codex_model_provider::create_model_provider; use codex_protocol::ThreadId; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::W3cTraceContext; +use codex_rollout::StateDbHandle; use codex_state::log_db::LogDbLayer; -use futures::FutureExt; +use tokio::sync::Mutex; +use tokio::sync::Semaphore; use tokio::sync::broadcast; use tokio::sync::watch; use tokio::time::Duration; @@ -92,6 +86,7 @@ use tokio::time::timeout; use tracing::Instrument; const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); + #[derive(Clone)] struct ExternalAuthRefreshBridge { outgoing: Arc, @@ -162,47 +157,53 @@ impl ExternalAuth for ExternalAuthRefreshBridge { pub(crate) struct MessageProcessor { outgoing: Arc, - codex_message_processor: CodexMessageProcessor, - thread_manager: Arc, - config_api: ConfigApi, - device_key_api: DeviceKeyApi, - external_agent_config_api: ExternalAgentConfigApi, - fs_api: FsApi, - auth_manager: Arc, - analytics_events_client: AnalyticsEventsClient, - fs_watch_manager: FsWatchManager, - config: Arc, - config_warnings: Arc>, - rpc_transport: AppServerRpcTransport, - remote_control_handle: Option, + account_processor: AccountRequestProcessor, + apps_processor: AppsRequestProcessor, + catalog_processor: CatalogRequestProcessor, + command_exec_processor: CommandExecRequestProcessor, + process_exec_processor: ProcessExecRequestProcessor, + config_processor: ConfigRequestProcessor, + environment_processor: EnvironmentRequestProcessor, + external_agent_config_processor: ExternalAgentConfigRequestProcessor, + feedback_processor: FeedbackRequestProcessor, + fs_processor: FsRequestProcessor, + git_processor: GitRequestProcessor, + initialize_processor: InitializeRequestProcessor, + marketplace_processor: MarketplaceRequestProcessor, + mcp_processor: McpRequestProcessor, + plugin_processor: PluginRequestProcessor, + search_processor: SearchRequestProcessor, + thread_goal_processor: ThreadGoalRequestProcessor, + thread_processor: ThreadRequestProcessor, + turn_processor: TurnRequestProcessor, + windows_sandbox_processor: WindowsSandboxRequestProcessor, request_serialization_queues: RequestSerializationQueues, } #[derive(Debug)] pub(crate) struct ConnectionSessionState { - origin: ConnectionOrigin, pub(crate) rpc_gate: Arc, initialized: OnceLock, } #[derive(Debug)] -struct InitializedConnectionSessionState { - experimental_api_enabled: bool, - opted_out_notification_methods: HashSet, - app_server_client_name: String, - client_version: String, +pub(crate) struct InitializedConnectionSessionState { + pub(crate) experimental_api_enabled: bool, + pub(crate) opted_out_notification_methods: HashSet, + pub(crate) app_server_client_name: String, + pub(crate) client_version: String, + pub(crate) request_attestation: bool, } impl Default for ConnectionSessionState { fn default() -> Self { - Self::new(ConnectionOrigin::WebSocket) + Self::new() } } impl ConnectionSessionState { - pub(crate) fn new(origin: ConnectionOrigin) -> Self { + pub(crate) fn new() -> Self { Self { - origin, rpc_gate: Arc::new(ConnectionRpcGate::new()), initialized: OnceLock::new(), } @@ -212,10 +213,6 @@ impl ConnectionSessionState { self.initialized.get().is_some() } - fn allows_device_key_requests(&self) -> bool { - self.origin.allows_device_key_requests() - } - pub(crate) fn experimental_api_enabled(&self) -> bool { self.initialized .get() @@ -241,7 +238,13 @@ impl ConnectionSessionState { .map(|session| session.client_version.as_str()) } - fn initialize(&self, session: InitializedConnectionSessionState) -> Result<(), ()> { + pub(crate) fn request_attestation(&self) -> bool { + self.initialized + .get() + .is_some_and(|session| session.request_attestation) + } + + pub(crate) fn initialize(&self, session: InitializedConnectionSessionState) -> Result<(), ()> { self.initialized.set(session).map_err(|_| ()) } } @@ -255,11 +258,12 @@ pub(crate) struct MessageProcessorArgs { pub(crate) environment_manager: Arc, pub(crate) feedback: CodexFeedback, pub(crate) log_db: Option, + pub(crate) state_db: Option, pub(crate) config_warnings: Vec, pub(crate) session_source: SessionSource, pub(crate) auth_manager: Arc, + pub(crate) installation_id: String, pub(crate) rpc_transport: AppServerRpcTransport, - pub(crate) remote_control_handle: Option, pub(crate) plugin_startup_tasks: crate::PluginStartupTasks, } @@ -276,48 +280,154 @@ impl MessageProcessor { environment_manager, feedback, log_db, + state_db, config_warnings, session_source, auth_manager, + installation_id, rpc_transport, - remote_control_handle, plugin_startup_tasks, } = args; auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); + let thread_state_manager = ThreadStateManager::new(); // The thread store is intentionally process-scoped. Config reloads can // affect per-thread behavior, but they must not move newly started, // resumed, or forked threads to a different persistence backend/root. - let thread_store = thread_store_from_config(config.as_ref()); - let thread_manager = Arc::new(ThreadManager::new( - config.as_ref(), - auth_manager.clone(), - session_source, - environment_manager, - Some(analytics_events_client.clone()), - Arc::clone(&thread_store), - )); + let thread_store = codex_core::thread_store_from_config(config.as_ref(), state_db.clone()); + let thread_manager = Arc::new_cyclic(|thread_manager| { + ThreadManager::new( + config.as_ref(), + auth_manager.clone(), + session_source, + environment_manager, + thread_extensions(guardian_agent_spawner(thread_manager.clone())), + Some(analytics_events_client.clone()), + Arc::clone(&thread_store), + state_db.clone(), + installation_id, + Some(app_server_attestation_provider( + outgoing.clone(), + thread_state_manager.clone(), + )), + ) + }); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); + let skills_watcher = SkillsWatcher::new(thread_manager.skills_manager(), outgoing.clone()); - let codex_message_processor = CodexMessageProcessor::new(CodexMessageProcessorArgs { - 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), - config_manager: config_manager.clone(), - thread_store, + let pending_thread_unloads = Arc::new(Mutex::new(HashSet::new())); + let thread_watch_manager = + crate::thread_status::ThreadWatchManager::new_with_outgoing(outgoing.clone()); + let thread_list_state_permit = Arc::new(Semaphore::new(/*permits*/ 1)); + let workspace_settings_cache = + Arc::new(workspace_settings::WorkspaceSettingsCache::default()); + let account_processor = AccountRequestProcessor::new( + auth_manager.clone(), + Arc::clone(&thread_manager), + outgoing.clone(), + Arc::clone(&config), + config_manager.clone(), + ); + let apps_processor = AppsRequestProcessor::new( + auth_manager.clone(), + Arc::clone(&thread_manager), + outgoing.clone(), + config_manager.clone(), + Arc::clone(&workspace_settings_cache), + ); + let catalog_processor = CatalogRequestProcessor::new( + auth_manager.clone(), + Arc::clone(&thread_manager), + Arc::clone(&config), + config_manager.clone(), + Arc::clone(&workspace_settings_cache), + ); + let command_exec_processor = CommandExecRequestProcessor::new( + arg0_paths.clone(), + Arc::clone(&config), + outgoing.clone(), + ); + let process_exec_processor = ProcessExecRequestProcessor::new(outgoing.clone()); + let feedback_processor = FeedbackRequestProcessor::new( + auth_manager.clone(), + Arc::clone(&thread_manager), + Arc::clone(&config), feedback, log_db, - }); + state_db.clone(), + ); + let git_processor = GitRequestProcessor::new(); + let initialize_processor = InitializeRequestProcessor::new( + outgoing.clone(), + analytics_events_client.clone(), + Arc::clone(&config), + config_warnings, + rpc_transport, + ); + let marketplace_processor = MarketplaceRequestProcessor::new( + Arc::clone(&config), + config_manager.clone(), + Arc::clone(&thread_manager), + ); + let mcp_processor = McpRequestProcessor::new( + auth_manager.clone(), + Arc::clone(&thread_manager), + outgoing.clone(), + config_manager.clone(), + ); + let plugin_processor = PluginRequestProcessor::new( + auth_manager.clone(), + Arc::clone(&thread_manager), + outgoing.clone(), + analytics_events_client.clone(), + config_manager.clone(), + workspace_settings_cache, + ); + let search_processor = SearchRequestProcessor::new(outgoing.clone()); + let thread_goal_processor = ThreadGoalRequestProcessor::new( + Arc::clone(&thread_manager), + outgoing.clone(), + Arc::clone(&config), + thread_state_manager.clone(), + state_db.clone(), + ); + let thread_processor = ThreadRequestProcessor::new( + auth_manager.clone(), + Arc::clone(&thread_manager), + outgoing.clone(), + arg0_paths.clone(), + Arc::clone(&config), + config_manager.clone(), + Arc::clone(&thread_store), + Arc::clone(&pending_thread_unloads), + thread_state_manager.clone(), + thread_watch_manager.clone(), + Arc::clone(&thread_list_state_permit), + thread_goal_processor.clone(), + state_db, + Arc::clone(&skills_watcher), + ); + let turn_processor = TurnRequestProcessor::new( + auth_manager.clone(), + Arc::clone(&thread_manager), + outgoing.clone(), + analytics_events_client.clone(), + arg0_paths.clone(), + Arc::clone(&config), + config_manager.clone(), + pending_thread_unloads, + thread_state_manager, + thread_watch_manager, + thread_list_state_permit, + Arc::clone(&skills_watcher), + ); if matches!(plugin_startup_tasks, crate::PluginStartupTasks::Start) { // Keep plugin startup warmups aligned at app-server startup. let on_effective_plugins_changed = - codex_message_processor.effective_plugins_changed_callback((*config).clone()); + plugin_processor.effective_plugins_changed_callback(); thread_manager .plugins_manager() .maybe_start_plugin_startup_tasks_for_config( @@ -326,44 +436,65 @@ impl MessageProcessor { Some(on_effective_plugins_changed), ); } - let config_api = ConfigApi::new( - config_manager, + let fs_watch_manager = FsWatchManager::new(outgoing.clone()); + let config_processor = ConfigRequestProcessor::new( + outgoing.clone(), + config_manager.clone(), + auth_manager, thread_manager.clone(), - analytics_events_client.clone(), + analytics_events_client, ); - let device_key_api = - DeviceKeyApi::new(config.sqlite_home.clone(), config.model_provider_id.clone()); - let external_agent_config_api = - ExternalAgentConfigApi::new(config.codex_home.to_path_buf()); - let fs_api = FsApi::new( + let external_agent_config_processor = ExternalAgentConfigRequestProcessor::new( + outgoing.clone(), + Arc::clone(&thread_manager), + config_manager.clone(), + config_processor.clone(), + arg0_paths, + config.codex_home.to_path_buf(), + ); + let environment_processor = + EnvironmentRequestProcessor::new(thread_manager.environment_manager()); + let fs_processor = FsRequestProcessor::new( thread_manager .environment_manager() .local_environment() .get_filesystem(), + fs_watch_manager, + ); + let windows_sandbox_processor = WindowsSandboxRequestProcessor::new( + outgoing.clone(), + Arc::clone(&config), + config_manager, ); - let fs_watch_manager = FsWatchManager::new(outgoing.clone()); Self { outgoing, - codex_message_processor, - thread_manager: Arc::clone(&thread_manager), - config_api, - device_key_api, - external_agent_config_api, - fs_api, - auth_manager, - analytics_events_client, - fs_watch_manager, - config, - config_warnings: Arc::new(config_warnings), - rpc_transport, - remote_control_handle, + account_processor, + apps_processor, + catalog_processor, + command_exec_processor, + process_exec_processor, + config_processor, + environment_processor, + external_agent_config_processor, + feedback_processor, + fs_processor, + git_processor, + initialize_processor, + marketplace_processor, + mcp_processor, + plugin_processor, + search_processor, + thread_goal_processor, + thread_processor, + turn_processor, + windows_sandbox_processor, request_serialization_queues: RequestSerializationQueues::default(), } } pub(crate) fn clear_runtime_references(&self) { - self.auth_manager.clear_external_auth(); + self.account_processor.clear_external_auth(); } pub(crate) async fn process_request( @@ -394,25 +525,29 @@ impl MessageProcessor { Arc::clone(&self.outgoing), request_context.clone(), async { - let result = async { - let request_json = serde_json::to_value(&request) - .map_err(|err| invalid_request(format!("Invalid request: {err}")))?; - let codex_request = serde_json::from_value::(request_json) - .map_err(|err| invalid_request(format!("Invalid request: {err}")))?; - // Websocket callers finalize outbound readiness in lib.rs after mirroring - // session state into outbound state and sending initialize notifications to - // this specific connection. Passing `None` avoids marking the connection - // ready too early from inside the shared request handler. - self.handle_client_request( - request_id.clone(), - codex_request, - Arc::clone(&session), - /*outbound_initialized*/ None, - request_context.clone(), - ) - .await - } - .await; + let codex_request = serde_json::to_value(&request) + .map_err(|err| invalid_request(format!("Invalid request: {err}"))) + .and_then(|request_json| { + serde_json::from_value::(request_json) + .map_err(|err| invalid_request(format!("Invalid request: {err}"))) + }); + let result = match codex_request { + Ok(codex_request) => { + // Websocket callers finalize outbound readiness in lib.rs after mirroring + // session state into outbound state and sending initialize notifications to + // this specific connection. Passing `None` avoids marking the connection + // ready too early from inside the shared request handler. + self.handle_client_request( + request_id.clone(), + codex_request, + Arc::clone(&session), + /*outbound_initialized*/ None, + request_context.clone(), + ) + .await + } + Err(error) => Err(error), + }; if let Err(error) = result { self.outgoing.send_error(request_id.clone(), error).await; } @@ -496,35 +631,37 @@ impl MessageProcessor { } pub(crate) fn thread_created_receiver(&self) -> broadcast::Receiver { - self.codex_message_processor.thread_created_receiver() + self.thread_processor.thread_created_receiver() } pub(crate) async fn send_initialize_notifications_to_connection( &self, connection_id: ConnectionId, ) { - for notification in self.config_warnings.iter().cloned() { - self.outgoing - .send_server_notification_to_connections( - &[connection_id], - ServerNotification::ConfigWarning(notification), - ) - .await; - } + self.initialize_processor + .send_initialize_notifications_to_connection(connection_id) + .await; } - pub(crate) async fn connection_initialized(&self, connection_id: ConnectionId) { - self.codex_message_processor - .connection_initialized(connection_id) + pub(crate) async fn connection_initialized( + &self, + connection_id: ConnectionId, + request_attestation: bool, + ) { + self.thread_processor + .connection_initialized( + connection_id, + ConnectionCapabilities { + request_attestation, + }, + ) .await; } pub(crate) async fn send_initialize_notifications(&self) { - for notification in self.config_warnings.iter().cloned() { - self.outgoing - .send_server_notification(ServerNotification::ConfigWarning(notification)) - .await; - } + self.initialize_processor + .send_initialize_notifications() + .await; } pub(crate) async fn try_attach_thread_listener( @@ -532,27 +669,25 @@ impl MessageProcessor { thread_id: ThreadId, connection_ids: Vec, ) { - self.codex_message_processor + self.thread_processor .try_attach_thread_listener(thread_id, connection_ids) .await; } pub(crate) async fn drain_background_tasks(&self) { - self.codex_message_processor.drain_background_tasks().await; + self.thread_processor.drain_background_tasks().await; } pub(crate) async fn cancel_active_login(&self) { - self.codex_message_processor.cancel_active_login().await; + self.account_processor.cancel_active_login().await; } pub(crate) async fn clear_all_thread_listeners(&self) { - self.codex_message_processor - .clear_all_thread_listeners() - .await; + self.thread_processor.clear_all_thread_listeners().await; } pub(crate) async fn shutdown_threads(&self) { - self.codex_message_processor.shutdown_threads().await; + self.thread_processor.shutdown_threads().await; } pub(crate) async fn connection_closed( @@ -562,14 +697,18 @@ impl MessageProcessor { ) { session_state.rpc_gate.shutdown().await; self.outgoing.connection_closed(connection_id).await; - self.fs_watch_manager.connection_closed(connection_id).await; - self.codex_message_processor + self.fs_processor.connection_closed(connection_id).await; + self.command_exec_processor .connection_closed(connection_id) .await; + self.process_exec_processor + .connection_closed(connection_id) + .await; + self.thread_processor.connection_closed(connection_id).await; } pub(crate) fn subscribe_running_assistant_turn_count(&self) -> watch::Receiver { - self.codex_message_processor + self.thread_processor .subscribe_running_assistant_turn_count() } @@ -599,110 +738,24 @@ impl MessageProcessor { ) -> Result<(), JSONRPCErrorError> { let connection_id = connection_request_id.connection_id; if let ClientRequest::Initialize { request_id, params } = codex_request { - // Handle Initialize internally so CodexMessageProcessor does not have to concern - // itself with the `initialized` bool. - let connection_request_id = ConnectionRequestId { - connection_id, - request_id, - }; - if session.initialized() { - return Err(invalid_request("Already initialized")); - } - - // TODO(maxj): Revisit capability scoping for `experimental_api_enabled`. - // Current behavior is per-connection. Reviewer feedback notes this can - // create odd cross-client behavior (for example dynamic tool calls on a - // 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) => ( - capabilities.experimental_api, - capabilities - .opt_out_notification_methods - .unwrap_or_default(), - ), - None => (false, Vec::new()), - }; - let ClientInfo { - name, - title: _title, - version, - } = params.client_info; - // Validate before committing; set_default_originator validates while - // mutating process-global metadata. - if HeaderValue::from_str(&name).is_err() { - return Err(invalid_request(format!( - "Invalid clientInfo.name: '{name}'. Must be a valid HTTP header value." - ))); - } - let originator = name.clone(); - let user_agent_suffix = format!("{name}; {version}"); - let codex_home = self.config.codex_home.clone(); - if session - .initialize(InitializedConnectionSessionState { - experimental_api_enabled, - opted_out_notification_methods: opt_out_notification_methods - .into_iter() - .collect(), - app_server_client_name: name.clone(), - client_version: version, - }) - .is_err() - { - return Err(invalid_request("Already initialized")); - } - - // Only the request that wins session initialization may mutate - // process-global client metadata. - if let Err(error) = set_default_originator(originator.clone()) { - match error { - SetOriginatorError::InvalidHeaderValue => { - tracing::warn!( - client_info_name = %name, - "validated clientInfo.name was rejected while setting originator" - ); - } - SetOriginatorError::AlreadyInitialized => { - // No-op. This is expected to happen if the originator is already set via env var. - // TODO(owen): Once we remove support for CODEX_INTERNAL_ORIGINATOR_OVERRIDE, - // this will be an unexpected state and we can return a JSON-RPC error indicating - // internal server error. - } - } - } - self.analytics_events_client.track_initialize( - connection_id.0, - analytics_initialize_params, - originator, - self.rpc_transport, - ); - set_default_client_residency_requirement(self.config.enforce_residency.value()); - if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() { - *suffix = Some(user_agent_suffix); - } - - let user_agent = get_codex_user_agent(); - let response = InitializeResponse { - user_agent, - codex_home, - platform_family: std::env::consts::FAMILY.to_string(), - platform_os: std::env::consts::OS.to_string(), - }; - - self.outgoing - .send_response(connection_request_id, response) - .await; - - if let Some(outbound_initialized) = outbound_initialized { - // In-process clients can complete readiness immediately here. The - // websocket path defers this until lib.rs finishes transport-layer - // initialize handling for the specific connection. - outbound_initialized.store(true, Ordering::Release); - self.codex_message_processor - .connection_initialized(connection_id) + let connection_initialized = self + .initialize_processor + .initialize( + connection_id, + request_id, + params, + &session, + outbound_initialized, + ) + .await?; + if connection_initialized { + self.thread_processor + .connection_initialized( + connection_id, + ConnectionCapabilities { + request_attestation: session.request_attestation(), + }, + ) .await; } return Ok(()); @@ -734,8 +787,8 @@ impl MessageProcessor { return Err(invalid_request(experimental_required_message(reason))); } let connection_id = connection_request_id.connection_id; - self.analytics_events_client.track_request( - connection_id.0, + self.initialize_processor.track_initialized_request( + connection_id, connection_request_id.request_id.clone(), &codex_request, ); @@ -743,7 +796,6 @@ impl MessageProcessor { let serialization_scope = codex_request.serialization_scope(); let app_server_client_name = session.app_server_client_name().map(str::to_string); let client_version = session.client_version().map(str::to_string); - let device_key_requests_allowed = session.allows_device_key_requests(); let error_request_id = connection_request_id.clone(); let rpc_gate = Arc::clone(&session.rpc_gate); let processor = Arc::clone(self); @@ -759,7 +811,6 @@ impl MessageProcessor { request_context, app_server_client_name, client_version, - device_key_requests_allowed, ) .await; if let Err(error) = result { @@ -770,9 +821,9 @@ impl MessageProcessor { ); if let Some(scope) = serialization_scope { - let key = RequestSerializationQueueKey::from_scope(connection_id, scope); + let (key, access) = RequestSerializationQueueKey::from_scope(connection_id, scope); self.request_serialization_queues - .enqueue(key, request) + .enqueue(key, access, request) .await; } else { tokio::spawn(async move { @@ -789,593 +840,479 @@ impl MessageProcessor { request_context: RequestContext, app_server_client_name: Option, client_version: Option, - device_key_requests_allowed: bool, ) -> Result<(), JSONRPCErrorError> { let connection_id = connection_request_id.connection_id; - let request_id_for_connection = |request_id| ConnectionRequestId { + let request_id = ConnectionRequestId { connection_id, - request_id, + request_id: codex_request.id().clone(), }; - match codex_request { - ClientRequest::ConfigRead { request_id, params } => { - self.outgoing - .send_result( - request_id_for_connection(request_id), - self.config_api.read(params).await, - ) - .await; + let result: Result, JSONRPCErrorError> = match codex_request { + ClientRequest::Initialize { .. } => { + panic!("Initialize should be handled before initialized request dispatch"); } - ClientRequest::ExternalAgentConfigDetect { request_id, params } => { - self.outgoing - .send_result( - request_id_for_connection(request_id), - self.external_agent_config_api.detect(params).await, - ) - .await; + ClientRequest::ConfigRead { params, .. } => self + .config_processor + .read(params) + .await + .map(|response| Some(response.into())), + ClientRequest::WindowsSandboxReadiness { .. } => self + .windows_sandbox_processor + .windows_sandbox_readiness() + .await + .map(|response| Some(response.into())), + ClientRequest::ExternalAgentConfigDetect { params, .. } => self + .external_agent_config_processor + .detect(params) + .await + .map(|response| Some(response.into())), + ClientRequest::ExternalAgentConfigImport { params, .. } => self + .external_agent_config_processor + .import(request_id.clone(), params) + .await + .map(|()| None), + ClientRequest::ConfigValueWrite { params, .. } => { + self.config_processor.value_write(params).await.map(Some) } - ClientRequest::ExternalAgentConfigImport { request_id, params } => { - self.handle_external_agent_config_import( - request_id_for_connection(request_id), - params, - ) - .await?; + ClientRequest::ConfigBatchWrite { params, .. } => { + self.config_processor.batch_write(params).await.map(Some) } - ClientRequest::ConfigValueWrite { request_id, params } => { - self.handle_config_value_write(request_id_for_connection(request_id), params) - .await; + ClientRequest::ExperimentalFeatureEnablementSet { params, .. } => { + self.config_processor + .experimental_feature_enablement_set(request_id.clone(), params) + .await } - ClientRequest::ConfigBatchWrite { request_id, params } => { - self.handle_config_batch_write(request_id_for_connection(request_id), params) - .await; + ClientRequest::ConfigRequirementsRead { params: _, .. } => self + .config_processor + .config_requirements_read() + .await + .map(|response| Some(response.into())), + ClientRequest::EnvironmentAdd { params, .. } => { + self.environment_processor.environment_add(params).await } - ClientRequest::ExperimentalFeatureEnablementSet { request_id, params } => { - self.handle_experimental_feature_enablement_set( - request_id_for_connection(request_id), - params, - ) - .await; - } - ClientRequest::ConfigRequirementsRead { - request_id, - params: _, - } => { - self.outgoing - .send_result( - request_id_for_connection(request_id), - self.config_api.config_requirements_read().await, - ) - .await; - } - ClientRequest::DeviceKeyCreate { request_id, params } => { - self.handle_device_key_create( - request_id_for_connection(request_id), - params, - device_key_requests_allowed, - ); - } - ClientRequest::DeviceKeyPublic { request_id, params } => { - self.handle_device_key_public( - request_id_for_connection(request_id), - params, - device_key_requests_allowed, - ); - } - ClientRequest::DeviceKeySign { request_id, params } => { - self.handle_device_key_sign( - request_id_for_connection(request_id), - params, - device_key_requests_allowed, - ); - } - ClientRequest::FsReadFile { request_id, params } => { - self.outgoing - .send_result( - request_id_for_connection(request_id), - self.fs_api.read_file(params).await, - ) - .await; - } - ClientRequest::FsWriteFile { request_id, params } => { - self.outgoing - .send_result( - request_id_for_connection(request_id), - self.fs_api.write_file(params).await, - ) - .await; - } - ClientRequest::FsCreateDirectory { request_id, params } => { - self.outgoing - .send_result( - request_id_for_connection(request_id), - self.fs_api.create_directory(params).await, - ) - .await; - } - ClientRequest::FsGetMetadata { request_id, params } => { - self.outgoing - .send_result( - request_id_for_connection(request_id), - self.fs_api.get_metadata(params).await, - ) - .await; - } - ClientRequest::FsReadDirectory { request_id, params } => { - self.outgoing - .send_result( - request_id_for_connection(request_id), - self.fs_api.read_directory(params).await, - ) - .await; - } - ClientRequest::FsRemove { request_id, params } => { - self.outgoing - .send_result( - request_id_for_connection(request_id), - self.fs_api.remove(params).await, - ) - .await; - } - ClientRequest::FsCopy { request_id, params } => { - self.outgoing - .send_result( - request_id_for_connection(request_id), - self.fs_api.copy(params).await, - ) - .await; - } - ClientRequest::FsWatch { request_id, params } => { - self.outgoing - .send_result( - request_id_for_connection(request_id), - self.fs_watch_manager.watch(connection_id, params).await, - ) - .await; - } - ClientRequest::FsUnwatch { request_id, params } => { - self.outgoing - .send_result( - request_id_for_connection(request_id), - self.fs_watch_manager.unwatch(connection_id, params).await, - ) - .await; - } - ClientRequest::ModelProviderCapabilitiesRead { - request_id, - params: _, - } => { - self.handle_model_provider_capabilities_read(request_id_for_connection(request_id)) - .await; - } - other => { - // Box the delegated future so this wrapper's async state machine does not - // inline the full `CodexMessageProcessor::process_request` future, which - // can otherwise push worker-thread stack usage over the edge. - self.codex_message_processor - .process_request( - connection_id, - other, - app_server_client_name, - client_version, + ClientRequest::FsReadFile { params, .. } => self + .fs_processor + .read_file(params) + .await + .map(|response| Some(response.into())), + ClientRequest::FsWriteFile { params, .. } => self + .fs_processor + .write_file(params) + .await + .map(|response| Some(response.into())), + ClientRequest::FsCreateDirectory { params, .. } => self + .fs_processor + .create_directory(params) + .await + .map(|response| Some(response.into())), + ClientRequest::FsGetMetadata { params, .. } => self + .fs_processor + .get_metadata(params) + .await + .map(|response| Some(response.into())), + ClientRequest::FsReadDirectory { params, .. } => self + .fs_processor + .read_directory(params) + .await + .map(|response| Some(response.into())), + ClientRequest::FsRemove { params, .. } => self + .fs_processor + .remove(params) + .await + .map(|response| Some(response.into())), + ClientRequest::FsCopy { params, .. } => self + .fs_processor + .copy(params) + .await + .map(|response| Some(response.into())), + ClientRequest::FsWatch { params, .. } => self + .fs_processor + .watch(connection_id, params) + .await + .map(|response| Some(response.into())), + ClientRequest::FsUnwatch { params, .. } => self + .fs_processor + .unwatch(connection_id, params) + .await + .map(|response| Some(response.into())), + ClientRequest::ModelProviderCapabilitiesRead { params: _, .. } => self + .config_processor + .model_provider_capabilities_read() + .await + .map(|response| Some(response.into())), + ClientRequest::ThreadStart { params, .. } => { + self.thread_processor + .thread_start( + request_id.clone(), + params, + app_server_client_name.clone(), + client_version.clone(), request_context, ) - .boxed() - .await; + .await } - } - Ok(()) - } - - async fn handle_model_provider_capabilities_read(&self, request_id: ConnectionRequestId) { - let result = async { - let config = self - .config_api - .load_latest_config(/*fallback_cwd*/ None) - .await?; - let provider = create_model_provider(config.model_provider, /*auth_manager*/ None); - let capabilities = provider.capabilities(); - Ok::<_, JSONRPCErrorError>(ModelProviderCapabilitiesReadResponse { - namespace_tools: capabilities.namespace_tools, - image_generation: capabilities.image_generation, - web_search: capabilities.web_search, - }) - } - .await; - self.outgoing.send_result(request_id, result).await; - } - - async fn handle_config_value_write( - &self, - request_id: ConnectionRequestId, - params: ConfigValueWriteParams, - ) { - let result = self.config_api.write_value(params).await; - self.handle_config_mutation_result( - request_id, - result, - ClientResponsePayload::ConfigValueWrite, - ) - .await - } - - async fn handle_config_batch_write( - &self, - request_id: ConnectionRequestId, - params: ConfigBatchWriteParams, - ) { - let result = self.config_api.batch_write(params).await; - self.handle_config_mutation_result( - request_id, - result, - ClientResponsePayload::ConfigBatchWrite, - ) - .await; - } - - async fn handle_experimental_feature_enablement_set( - &self, - request_id: ConnectionRequestId, - params: ExperimentalFeatureEnablementSetParams, - ) { - let should_refresh_apps_list = params.enablement.get("apps").copied() == Some(true); - let result = self - .config_api - .set_experimental_feature_enablement(params) - .await; - let is_ok = result.is_ok(); - self.handle_config_mutation_result( - request_id, - result, - ClientResponsePayload::ExperimentalFeatureEnablementSet, - ) - .await; - if should_refresh_apps_list && is_ok { - self.refresh_apps_list_after_experimental_feature_enablement_set() - .await; - } - } - - async fn refresh_apps_list_after_experimental_feature_enablement_set(&self) { - let config = match self - .config_api - .load_latest_config(/*fallback_cwd*/ None) - .await - { - Ok(config) => config, - Err(error) => { - tracing::warn!( - "failed to load config for apps list refresh after experimental feature enablement: {}", - error.message - ); - return; + ClientRequest::ThreadUnsubscribe { params, .. } => { + self.thread_processor + .thread_unsubscribe(&request_id, params) + .await + } + ClientRequest::ThreadResume { params, .. } => { + self.thread_processor + .thread_resume( + request_id.clone(), + params, + app_server_client_name.clone(), + client_version.clone(), + ) + .await + } + ClientRequest::ThreadFork { params, .. } => { + self.thread_processor + .thread_fork( + request_id.clone(), + params, + app_server_client_name.clone(), + client_version.clone(), + ) + .await + } + ClientRequest::ThreadArchive { params, .. } => { + self.thread_processor + .thread_archive(request_id.clone(), params) + .await + } + ClientRequest::ThreadIncrementElicitation { params, .. } => { + self.thread_processor + .thread_increment_elicitation(params) + .await + } + ClientRequest::ThreadDecrementElicitation { params, .. } => { + self.thread_processor + .thread_decrement_elicitation(params) + .await + } + ClientRequest::ThreadSetName { params, .. } => { + self.thread_processor + .thread_set_name(request_id.clone(), params) + .await + } + ClientRequest::ThreadGoalSet { params, .. } => { + self.thread_goal_processor + .thread_goal_set(request_id.clone(), params) + .await + } + ClientRequest::ThreadGoalGet { params, .. } => { + self.thread_goal_processor.thread_goal_get(params).await + } + ClientRequest::ThreadGoalClear { params, .. } => { + self.thread_goal_processor + .thread_goal_clear(request_id.clone(), params) + .await + } + ClientRequest::ThreadMetadataUpdate { params, .. } => { + self.thread_processor.thread_metadata_update(params).await + } + ClientRequest::ThreadMemoryModeSet { params, .. } => { + self.thread_processor.thread_memory_mode_set(params).await + } + ClientRequest::MemoryReset { .. } => self.thread_processor.memory_reset().await, + ClientRequest::ThreadUnarchive { params, .. } => { + self.thread_processor + .thread_unarchive(request_id.clone(), params) + .await + } + ClientRequest::ThreadCompactStart { params, .. } => { + self.thread_processor + .thread_compact_start(&request_id, params) + .await + } + ClientRequest::ThreadBackgroundTerminalsClean { params, .. } => { + self.thread_processor + .thread_background_terminals_clean(&request_id, params) + .await + } + ClientRequest::ThreadRollback { params, .. } => { + self.thread_processor + .thread_rollback(&request_id, params) + .await + } + ClientRequest::ThreadList { params, .. } => { + self.thread_processor.thread_list(params).await + } + ClientRequest::ThreadLoadedList { params, .. } => { + self.thread_processor.thread_loaded_list(params).await + } + ClientRequest::ThreadRead { params, .. } => { + self.thread_processor.thread_read(params).await + } + ClientRequest::ThreadTurnsList { params, .. } => { + self.thread_processor.thread_turns_list(params).await + } + ClientRequest::ThreadTurnsItemsList { params, .. } => { + self.thread_processor.thread_turns_items_list(params).await + } + ClientRequest::ThreadShellCommand { params, .. } => { + self.thread_processor + .thread_shell_command(&request_id, params) + .await + } + ClientRequest::ThreadApproveGuardianDeniedAction { params, .. } => { + self.thread_processor + .thread_approve_guardian_denied_action(&request_id, params) + .await + } + ClientRequest::GetConversationSummary { params, .. } => { + self.thread_processor.conversation_summary(params).await + } + ClientRequest::SkillsList { params, .. } => { + self.catalog_processor.skills_list(params).await + } + ClientRequest::HooksList { params, .. } => { + self.catalog_processor.hooks_list(params).await + } + ClientRequest::MarketplaceAdd { params, .. } => { + self.marketplace_processor.marketplace_add(params).await + } + ClientRequest::MarketplaceRemove { params, .. } => { + self.marketplace_processor.marketplace_remove(params).await + } + ClientRequest::MarketplaceUpgrade { params, .. } => { + self.marketplace_processor.marketplace_upgrade(params).await + } + ClientRequest::PluginList { params, .. } => { + self.plugin_processor.plugin_list(params).await + } + ClientRequest::PluginRead { params, .. } => { + self.plugin_processor.plugin_read(params).await + } + ClientRequest::PluginSkillRead { params, .. } => { + self.plugin_processor.plugin_skill_read(params).await + } + ClientRequest::PluginShareSave { params, .. } => { + self.plugin_processor.plugin_share_save(params).await + } + ClientRequest::PluginShareUpdateTargets { params, .. } => { + self.plugin_processor + .plugin_share_update_targets(params) + .await + } + ClientRequest::PluginShareList { params, .. } => { + self.plugin_processor.plugin_share_list(params).await + } + ClientRequest::PluginShareDelete { params, .. } => { + self.plugin_processor.plugin_share_delete(params).await + } + ClientRequest::AppsList { params, .. } => { + self.apps_processor.apps_list(&request_id, params).await + } + ClientRequest::SkillsConfigWrite { params, .. } => { + self.catalog_processor.skills_config_write(params).await + } + ClientRequest::PluginInstall { params, .. } => { + self.plugin_processor.plugin_install(params).await + } + ClientRequest::PluginUninstall { params, .. } => { + self.plugin_processor.plugin_uninstall(params).await + } + ClientRequest::ModelList { params, .. } => { + self.catalog_processor.model_list(params).await + } + ClientRequest::ExperimentalFeatureList { params, .. } => { + self.catalog_processor + .experimental_feature_list(params) + .await + } + ClientRequest::CollaborationModeList { params, .. } => { + self.catalog_processor.collaboration_mode_list(params).await + } + ClientRequest::MockExperimentalMethod { params, .. } => { + self.catalog_processor + .mock_experimental_method(params) + .await + } + ClientRequest::TurnStart { params, .. } => { + self.turn_processor + .turn_start( + request_id.clone(), + params, + app_server_client_name.clone(), + client_version.clone(), + ) + .await + } + ClientRequest::ThreadInjectItems { params, .. } => { + self.turn_processor.thread_inject_items(params).await + } + ClientRequest::TurnSteer { params, .. } => { + self.turn_processor.turn_steer(&request_id, params).await + } + ClientRequest::TurnInterrupt { params, .. } => { + self.turn_processor + .turn_interrupt(&request_id, params) + .await + } + ClientRequest::ThreadRealtimeStart { params, .. } => { + self.turn_processor + .thread_realtime_start(&request_id, params) + .await + } + ClientRequest::ThreadRealtimeAppendAudio { params, .. } => { + self.turn_processor + .thread_realtime_append_audio(&request_id, params) + .await + } + ClientRequest::ThreadRealtimeAppendText { params, .. } => { + self.turn_processor + .thread_realtime_append_text(&request_id, params) + .await + } + ClientRequest::ThreadRealtimeStop { params, .. } => { + self.turn_processor + .thread_realtime_stop(&request_id, params) + .await + } + ClientRequest::ThreadRealtimeListVoices { params: _, .. } => { + self.turn_processor.thread_realtime_list_voices().await + } + ClientRequest::ReviewStart { params, .. } => { + self.turn_processor.review_start(&request_id, params).await + } + ClientRequest::McpServerOauthLogin { params, .. } => { + self.mcp_processor.mcp_server_oauth_login(params).await + } + ClientRequest::McpServerRefresh { params, .. } => { + self.mcp_processor.mcp_server_refresh(params).await + } + ClientRequest::McpServerStatusList { params, .. } => { + self.mcp_processor + .mcp_server_status_list(&request_id, params) + .await + } + ClientRequest::McpResourceRead { params, .. } => { + self.mcp_processor + .mcp_resource_read(&request_id, params) + .await + } + ClientRequest::McpServerToolCall { params, .. } => { + self.mcp_processor + .mcp_server_tool_call(&request_id, params) + .await + } + ClientRequest::WindowsSandboxSetupStart { params, .. } => { + self.windows_sandbox_processor + .windows_sandbox_setup_start(&request_id, params) + .await + } + ClientRequest::LoginAccount { params, .. } => { + self.account_processor + .login_account(request_id.clone(), params) + .await + } + ClientRequest::LogoutAccount { .. } => { + self.account_processor + .logout_account(request_id.clone()) + .await + } + ClientRequest::CancelLoginAccount { params, .. } => { + self.account_processor.cancel_login_account(params).await + } + ClientRequest::GetAccount { params, .. } => { + self.account_processor.get_account(params).await + } + ClientRequest::GetAuthStatus { params, .. } => { + self.account_processor.get_auth_status(params).await + } + ClientRequest::GetAccountRateLimits { .. } => { + self.account_processor.get_account_rate_limits().await + } + ClientRequest::SendAddCreditsNudgeEmail { params, .. } => { + self.account_processor + .send_add_credits_nudge_email(params) + .await + } + ClientRequest::GitDiffToRemote { params, .. } => { + self.git_processor.git_diff_to_remote(params).await + } + ClientRequest::FuzzyFileSearch { params, .. } => self + .search_processor + .fuzzy_file_search(params) + .await + .map(|response| Some(response.into())), + ClientRequest::FuzzyFileSearchSessionStart { params, .. } => self + .search_processor + .fuzzy_file_search_session_start_response(params) + .await + .map(|response| Some(response.into())), + ClientRequest::FuzzyFileSearchSessionUpdate { params, .. } => self + .search_processor + .fuzzy_file_search_session_update_response(params) + .await + .map(|response| Some(response.into())), + ClientRequest::FuzzyFileSearchSessionStop { params, .. } => self + .search_processor + .fuzzy_file_search_session_stop(params) + .await + .map(|response| Some(response.into())), + ClientRequest::OneOffCommandExec { params, .. } => { + self.command_exec_processor + .one_off_command_exec(&request_id, params) + .await + } + ClientRequest::CommandExecWrite { params, .. } => { + self.command_exec_processor + .command_exec_write(request_id.clone(), params) + .await + } + ClientRequest::CommandExecResize { params, .. } => { + self.command_exec_processor + .command_exec_resize(request_id.clone(), params) + .await + } + ClientRequest::CommandExecTerminate { params, .. } => { + self.command_exec_processor + .command_exec_terminate(request_id.clone(), params) + .await + } + ClientRequest::ProcessSpawn { params, .. } => self + .process_exec_processor + .process_spawn(request_id.clone(), params) + .await + .map(|()| None), + ClientRequest::ProcessWriteStdin { params, .. } => { + self.process_exec_processor + .process_write_stdin(request_id.clone(), params) + .await + } + ClientRequest::ProcessKill { params, .. } => { + self.process_exec_processor + .process_kill(request_id.clone(), params) + .await + } + ClientRequest::ProcessResizePty { params, .. } => { + self.process_exec_processor + .process_resize_pty(request_id.clone(), params) + .await + } + ClientRequest::FeedbackUpload { params, .. } => { + self.feedback_processor.feedback_upload(params).await } }; - let auth = self.auth_manager.auth().await; - if !config.features.apps_enabled_for_auth( - auth.as_ref() - .is_some_and(codex_login::CodexAuth::uses_codex_backend), - ) { - return; - } - let outgoing = Arc::clone(&self.outgoing); - let environment_manager = self.thread_manager.environment_manager(); - tokio::spawn(async move { - let (all_connectors_result, accessible_connectors_result) = tokio::join!( - connectors::list_all_connectors_with_options(&config, /*force_refetch*/ true), - connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( - &config, - /*force_refetch*/ true, - &environment_manager, - ), - ); - let all_connectors = match all_connectors_result { - Ok(connectors) => connectors, - Err(err) => { - tracing::warn!( - "failed to force-refresh directory apps after experimental feature enablement: {err:#}" - ); - return; - } - }; - let accessible_connectors = match accessible_connectors_result { - Ok(status) => status.connectors, - Err(err) => { - tracing::warn!( - "failed to force-refresh accessible apps after experimental feature enablement: {err:#}" - ); - return; - } - }; - - let data = connectors::with_app_enabled_state( - connectors::merge_connectors_with_accessible( - all_connectors, - accessible_connectors, - /*all_connectors_loaded*/ true, - ), - &config, - ); - outgoing - .send_server_notification(ServerNotification::AppListUpdated( - AppListUpdatedNotification { data }, - )) - .await; - }); - } - - async fn handle_config_mutation_result( - &self, - request_id: ConnectionRequestId, - result: std::result::Result, - wrap_success: impl FnOnce(T) -> ClientResponsePayload, - ) { match result { - Ok(response) => { - self.handle_config_mutation().await; + Ok(Some(response)) => { self.outgoing - .send_response_as(request_id, wrap_success(response)) + .send_response_as(request_id.clone(), response) .await; } - Err(error) => self.outgoing.send_error(request_id, error).await, - } - } - - async fn handle_config_mutation(&self) { - self.codex_message_processor.handle_config_mutation(); - let Some(remote_control_handle) = &self.remote_control_handle else { - return; - }; - - match self - .config_api - .load_latest_config(/*fallback_cwd*/ None) - .await - { - Ok(config) => { - remote_control_handle.set_enabled(config.features.enabled(Feature::RemoteControl)); - } + Ok(None) => {} Err(error) => { - tracing::warn!( - "failed to load config for remote control enablement refresh after config mutation: {}", - error.message - ); + self.outgoing.send_error(request_id.clone(), error).await; } } - } - - fn handle_device_key_create( - &self, - request_id: ConnectionRequestId, - params: DeviceKeyCreateParams, - device_key_requests_allowed: bool, - ) { - self.spawn_device_key_request( - request_id, - "device/key/create", - device_key_requests_allowed, - move |device_key_api| async move { device_key_api.create(params).await }, - ); - } - - fn handle_device_key_public( - &self, - request_id: ConnectionRequestId, - params: DeviceKeyPublicParams, - device_key_requests_allowed: bool, - ) { - self.spawn_device_key_request( - request_id, - "device/key/public", - device_key_requests_allowed, - move |device_key_api| async move { device_key_api.public(params).await }, - ); - } - - fn handle_device_key_sign( - &self, - request_id: ConnectionRequestId, - params: DeviceKeySignParams, - device_key_requests_allowed: bool, - ) { - self.spawn_device_key_request( - request_id, - "device/key/sign", - device_key_requests_allowed, - move |device_key_api| async move { device_key_api.sign(params).await }, - ); - } - - fn spawn_device_key_request( - &self, - request_id: ConnectionRequestId, - method: &'static str, - device_key_requests_allowed: bool, - run_request: F, - ) where - R: Into + Send + 'static, - F: FnOnce(DeviceKeyApi) -> Fut + Send + 'static, - Fut: Future> + Send + 'static, - { - let device_key_api = self.device_key_api.clone(); - let outgoing = Arc::clone(&self.outgoing); - tokio::spawn(async move { - let result = async { - if !device_key_requests_allowed { - return Err(invalid_request(format!( - "{method} is not available over remote transports" - ))); - } - run_request(device_key_api).await - } - .await; - outgoing.send_result(request_id, result).await; - }); - } - - async fn handle_external_agent_config_import( - &self, - request_id: ConnectionRequestId, - params: ExternalAgentConfigImportParams, - ) -> Result<(), JSONRPCErrorError> { - let needs_runtime_refresh = migration_items_need_runtime_refresh(¶ms.migration_items); - let has_migration_items = !params.migration_items.is_empty(); - let has_plugin_imports = params.migration_items.iter().any(|item| { - matches!( - item.item_type, - ExternalAgentConfigMigrationItemType::Plugins - ) - }); - let pending_session_imports = self - .external_agent_config_api - .validate_pending_session_imports(¶ms)?; - let pending_plugin_imports = self.external_agent_config_api.import(params).await?; - if needs_runtime_refresh { - self.handle_config_mutation().await; - } - self.outgoing - .send_response(request_id, ExternalAgentConfigImportResponse {}) - .await; - - if !has_migration_items { - return Ok(()); - } - - let has_background_imports = - !pending_plugin_imports.is_empty() || !pending_session_imports.is_empty(); - if !has_background_imports { - self.outgoing - .send_server_notification(ServerNotification::ExternalAgentConfigImportCompleted( - ExternalAgentConfigImportCompletedNotification {}, - )) - .await; - return Ok(()); - } - - let external_agent_config_api = self.external_agent_config_api.clone(); - let session_import_permits = external_agent_config_api.session_import_permits(); - let codex_message_processor = self.codex_message_processor.clone(); - let outgoing = Arc::clone(&self.outgoing); - let thread_manager = Arc::clone(&self.thread_manager); - tokio::spawn(async move { - let session_external_agent_config_api = external_agent_config_api.clone(); - let plugin_external_agent_config_api = external_agent_config_api; - let session_imports = async move { - if !pending_session_imports.is_empty() { - let Ok(_session_import_permit) = session_import_permits.acquire_owned().await - else { - return; - }; - let pending_session_imports = session_external_agent_config_api - .prepare_validated_session_imports(pending_session_imports); - for pending_session_import in pending_session_imports { - match codex_message_processor - .import_external_agent_session(pending_session_import.session) - .await - { - Ok(imported_thread_id) => { - session_external_agent_config_api.record_imported_session( - &pending_session_import.source_path, - imported_thread_id, - ); - } - Err(error) => { - tracing::warn!( - error = %error.message, - path = %pending_session_import.source_path.display(), - "external agent session import failed" - ); - } - } - } - } - }; - let plugin_imports = async move { - for pending_plugin_import in pending_plugin_imports { - match plugin_external_agent_config_api - .complete_pending_plugin_import(pending_plugin_import) - .await - { - Ok(()) => {} - Err(error) => { - tracing::warn!( - error = %error.message, - "external agent config plugin import failed" - ); - } - } - } - }; - tokio::join!(session_imports, plugin_imports); - if has_plugin_imports { - thread_manager.plugins_manager().clear_cache(); - thread_manager.skills_manager().clear_cache(); - } - outgoing - .send_server_notification(ServerNotification::ExternalAgentConfigImportCompleted( - ExternalAgentConfigImportCompletedNotification {}, - )) - .await; - }); - Ok(()) } } -fn migration_items_need_runtime_refresh(items: &[ExternalAgentConfigMigrationItem]) -> bool { - items.iter().any(|item| { - matches!( - item.item_type, - ExternalAgentConfigMigrationItemType::Config - | ExternalAgentConfigMigrationItemType::Skills - | ExternalAgentConfigMigrationItemType::McpServerConfig - | ExternalAgentConfigMigrationItemType::Hooks - | ExternalAgentConfigMigrationItemType::Commands - | ExternalAgentConfigMigrationItemType::Plugins - ) - }) -} - #[cfg(test)] -mod tracing_tests; - -#[cfg(test)] -mod tests { - use super::*; - - fn migration_item( - item_type: ExternalAgentConfigMigrationItemType, - ) -> ExternalAgentConfigMigrationItem { - ExternalAgentConfigMigrationItem { - item_type, - description: String::new(), - cwd: None, - details: None, - } - } - - #[test] - fn migration_items_that_update_runtime_sources_trigger_refresh() { - assert!(migration_items_need_runtime_refresh(&[migration_item( - ExternalAgentConfigMigrationItemType::Config, - )])); - assert!(migration_items_need_runtime_refresh(&[migration_item( - ExternalAgentConfigMigrationItemType::Skills, - )])); - assert!(migration_items_need_runtime_refresh(&[migration_item( - ExternalAgentConfigMigrationItemType::McpServerConfig, - )])); - assert!(migration_items_need_runtime_refresh(&[migration_item( - ExternalAgentConfigMigrationItemType::Hooks, - )])); - assert!(migration_items_need_runtime_refresh(&[migration_item( - ExternalAgentConfigMigrationItemType::Commands, - )])); - assert!(migration_items_need_runtime_refresh(&[migration_item( - ExternalAgentConfigMigrationItemType::Plugins, - )])); - assert!(!migration_items_need_runtime_refresh(&[migration_item( - ExternalAgentConfigMigrationItemType::Sessions, - )])); - } -} +#[path = "message_processor_tracing_tests.rs"] +mod message_processor_tracing_tests; diff --git a/codex-rs/app-server/src/message_processor/tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs similarity index 86% rename from codex-rs/app-server/src/message_processor/tracing_tests.rs rename to codex-rs/app-server/src/message_processor_tracing_tests.rs index 8caf1aaa96..452991270a 100644 --- a/codex-rs/app-server/src/message_processor/tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -6,21 +6,16 @@ use crate::config_manager::ConfigManager; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingMessageSender; use crate::transport::AppServerTransport; -use crate::transport::ConnectionOrigin; use anyhow::Result; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::write_mock_responses_config_toml; use codex_analytics::AppServerRpcTransport; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; -use codex_app_server_protocol::DeviceKeySignParams; -use codex_app_server_protocol::DeviceKeySignPayload; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::InitializeResponse; -use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::JSONRPCRequest; -use codex_app_server_protocol::RemoteControlClientConnectionAudience; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; @@ -121,10 +116,6 @@ struct TracingHarness { impl TracingHarness { async fn new() -> Result { - Self::new_with_origin(ConnectionOrigin::WebSocket).await - } - - async fn new_with_origin(origin: ConnectionOrigin) -> Result { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; let config = Arc::new(build_test_config(codex_home.path(), &server.uri()).await?); @@ -137,7 +128,7 @@ impl TracingHarness { _codex_home: codex_home, processor, outgoing_rx, - session: Arc::new(ConnectionSessionState::new(origin)), + session: Arc::new(ConnectionSessionState::new()), tracing, }; @@ -196,29 +187,6 @@ impl TracingHarness { read_response(&mut self.outgoing_rx, request_id).await } - async fn request_error( - &mut self, - request: ClientRequest, - trace: Option, - ) -> JSONRPCErrorError { - let request_id = match request.id() { - RequestId::Integer(request_id) => *request_id, - request_id => panic!("expected integer request id in test harness, got {request_id:?}"), - }; - let mut request = request_from_client_request(request); - request.trace = trace; - - self.processor - .process_request( - TEST_CONNECTION_ID, - request, - &AppServerTransport::Stdio, - Arc::clone(&self.session), - ) - .await; - read_error(&mut self.outgoing_rx, request_id).await - } - async fn start_thread( &mut self, request_id: i64, @@ -290,11 +258,12 @@ async fn build_test_processor( environment_manager: Arc::new(EnvironmentManager::default_for_tests()), feedback: CodexFeedback::new(), log_db: None, + state_db: None, config_warnings: Vec::new(), session_source: SessionSource::VSCode, auth_manager, + installation_id: "11111111-1111-4111-8111-111111111111".to_string(), rpc_transport: AppServerRpcTransport::Stdio, - remote_control_handle: None, plugin_startup_tasks: crate::PluginStartupTasks::Start, })); (processor, outgoing_rx) @@ -483,36 +452,6 @@ async fn read_response( } } -async fn read_error( - outgoing_rx: &mut mpsc::Receiver, - request_id: i64, -) -> JSONRPCErrorError { - loop { - let envelope = tokio::time::timeout(std::time::Duration::from_secs(5), outgoing_rx.recv()) - .await - .expect("timed out waiting for error") - .expect("outgoing channel closed"); - let crate::outgoing_message::OutgoingEnvelope::ToConnection { - connection_id, - message, - .. - } = envelope - else { - continue; - }; - if connection_id != TEST_CONNECTION_ID { - continue; - } - let crate::outgoing_message::OutgoingMessage::Error(error) = message else { - continue; - }; - if error.id != RequestId::Integer(request_id) { - continue; - } - return error.error; - } -} - async fn read_thread_started_notification( outgoing_rx: &mut mpsc::Receiver, ) { @@ -691,47 +630,6 @@ fn thread_start_jsonrpc_span_exports_server_span_and_parents_children() -> Resul ) } -#[tokio::test(flavor = "current_thread")] -#[serial(app_server_tracing)] -async fn remote_control_origin_rejects_device_key_requests() -> Result<()> { - let mut harness = TracingHarness::new_with_origin(ConnectionOrigin::RemoteControl).await?; - - let error = harness - .request_error( - ClientRequest::DeviceKeySign { - request_id: RequestId::Integer(20_004), - params: DeviceKeySignParams { - key_id: "dk_123".to_string(), - payload: DeviceKeySignPayload::RemoteControlClientConnection { - nonce: "nonce-123".to_string(), - audience: - RemoteControlClientConnectionAudience::RemoteControlClientWebsocket, - session_id: "wssess_123".to_string(), - target_origin: "https://chatgpt.com".to_string(), - target_path: "/api/codex/remote/control/client".to_string(), - account_user_id: "acct_123".to_string(), - client_id: "cli_123".to_string(), - token_expires_at: 4_102_444_800, - token_sha256_base64url: "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU" - .to_string(), - scopes: vec!["remote_control_controller_websocket".to_string()], - }, - }, - }, - /*trace*/ None, - ) - .await; - - assert_eq!(error.code, crate::error_code::INVALID_REQUEST_ERROR_CODE); - assert_eq!( - error.message, - "device/key/sign is not available over remote transports" - ); - - harness.shutdown().await; - Ok(()) -} - #[tokio::test(flavor = "current_thread")] #[serial(app_server_tracing)] async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs index fd08098f74..4d75a20580 100644 --- a/codex-rs/app-server/src/models.rs +++ b/codex-rs/app-server/src/models.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use codex_app_server_protocol::Model; +use codex_app_server_protocol::ModelServiceTier; use codex_app_server_protocol::ModelUpgradeInfo; use codex_app_server_protocol::ReasoningEffortOption; use codex_core::ThreadManager; @@ -43,6 +44,15 @@ fn model_from_preset(preset: ModelPreset) -> Model { input_modalities: preset.input_modalities, supports_personality: preset.supports_personality, additional_speed_tiers: preset.additional_speed_tiers, + service_tiers: preset + .service_tiers + .into_iter() + .map(|service_tier| ModelServiceTier { + id: service_tier.id, + name: service_tier.name, + description: service_tier.description, + }) + .collect(), is_default: preset.is_default, } } diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index f7a90538c2..97ef37f742 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; use std::sync::Arc; use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ClientResponsePayload; @@ -11,9 +13,11 @@ use codex_app_server_protocol::Result; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestPayload; +use codex_app_server_protocol::ServerResponse; use codex_otel::span_w3c_trace_context; use codex_protocol::ThreadId; use codex_protocol::protocol::W3cTraceContext; +use codex_protocol::request_permissions::RequestPermissionsResponse; use tokio::sync::Mutex; use tokio::sync::mpsc; use tokio::sync::oneshot; @@ -21,7 +25,6 @@ use tracing::Instrument; use tracing::Span; use tracing::warn; -use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::internal_error; use crate::server_request_error::TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON; pub(crate) use codex_app_server_transport::ConnectionId; @@ -140,7 +143,24 @@ impl ThreadScopedOutgoingMessageSender { .await } + pub(crate) fn track_effective_permissions_approval_response( + &self, + request_id: RequestId, + response: RequestPermissionsResponse, + ) { + self.outgoing + .analytics_events_client + .track_effective_permissions_approval_response( + now_unix_timestamp_ms(), + request_id, + response, + ); + } + pub(crate) async fn send_server_notification(&self, notification: ServerNotification) { + self.outgoing + .analytics_events_client + .track_notification(notification.clone()); if self.connection_ids.is_empty() { return; } @@ -157,11 +177,14 @@ impl ThreadScopedOutgoingMessageSender { self.outgoing .cancel_requests_for_thread( self.thread_id, - Some(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: "client request resolved because the turn state was changed" - .to_string(), - data: Some(serde_json::json!({ "reason": TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON })), + Some({ + let mut error = internal_error( + "client request resolved because the turn state was changed", + ); + error.data = Some(serde_json::json!({ + "reason": TURN_TRANSITION_PENDING_REQUEST_ERROR_REASON, + })); + error }), ) .await @@ -260,7 +283,7 @@ impl OutgoingMessageSender { RequestId::Integer(self.next_server_request_id.fetch_add(1, Ordering::Relaxed)) } - async fn send_request_to_connections( + pub(crate) async fn send_request_to_connections( &self, connection_ids: Option<&[ConnectionId]>, request: ServerRequestPayload, @@ -352,8 +375,12 @@ impl OutgoingMessageSender { match entry { Some((id, entry)) => { - if let Ok(response) = entry.request.response_from_result(result.clone()) { - self.analytics_events_client.track_server_response(response); + let completed_at_ms = now_unix_timestamp_ms(); + if let Ok(response) = entry.request.response_from_result(result.clone()) + && !matches!(response, ServerResponse::PermissionsRequestApproval { .. }) + { + self.analytics_events_client + .track_server_response(completed_at_ms, response); } if let Err(err) = entry.callback.send(Ok(result)) { warn!("could not notify callback for {id:?} due to: {err:?}"); @@ -371,6 +398,8 @@ impl OutgoingMessageSender { match entry { Some((id, entry)) => { warn!("client responded with error for {id:?}: {error:?}"); + self.analytics_events_client + .track_server_request_aborted(now_unix_timestamp_ms(), id.clone()); if let Err(err) = entry.callback.send(Err(error)) { warn!("could not notify callback for {id:?} due to: {err:?}"); } @@ -382,7 +411,14 @@ impl OutgoingMessageSender { } pub(crate) async fn cancel_request(&self, id: &RequestId) -> bool { - self.take_request_callback(id).await.is_some() + let entry = self.take_request_callback(id).await; + if let Some((request_id, _entry)) = entry { + self.analytics_events_client + .track_server_request_aborted(now_unix_timestamp_ms(), request_id); + true + } else { + false + } } pub(crate) async fn cancel_all_requests(&self, error: Option) { @@ -394,12 +430,14 @@ impl OutgoingMessageSender { .collect::>() }; - if let Some(error) = error { - for entry in entries { - if let Err(err) = entry.callback.send(Err(error.clone())) { - let request_id = entry.request.id(); - warn!("could not notify callback for {request_id:?} due to: {err:?}"); - } + for entry in entries { + self.analytics_events_client + .track_server_request_aborted(now_unix_timestamp_ms(), entry.request.id().clone()); + if let Some(error) = error.as_ref() + && let Err(err) = entry.callback.send(Err(error.clone())) + { + let request_id = entry.request.id(); + warn!("could not notify callback for {request_id:?} due to: {err:?}"); } } } @@ -450,12 +488,14 @@ impl OutgoingMessageSender { entries }; - if let Some(error) = error { - for entry in entries { - if let Err(err) = entry.callback.send(Err(error.clone())) { - let request_id = entry.request.id(); - warn!("could not notify callback for {request_id:?} due to: {err:?}",); - } + for entry in entries { + self.analytics_events_client + .track_server_request_aborted(now_unix_timestamp_ms(), entry.request.id().clone()); + if let Some(error) = error.as_ref() + && let Err(err) = entry.callback.send(Err(error.clone())) + { + let request_id = entry.request.id(); + warn!("could not notify callback for {request_id:?} due to: {err:?}",); } } } @@ -524,7 +564,7 @@ impl OutgoingMessageSender { targeted_connections = connection_ids.len(), "app-server event: {notification}" ); - let outgoing_message = OutgoingMessage::AppServerNotification(notification); + let outgoing_message = OutgoingMessage::AppServerNotification(notification.clone()); if connection_ids.is_empty() { if let Err(err) = self .sender @@ -558,7 +598,7 @@ impl OutgoingMessageSender { notification: ServerNotification, ) { tracing::trace!("app-server event: {notification}"); - let outgoing_message = OutgoingMessage::AppServerNotification(notification); + let outgoing_message = OutgoingMessage::AppServerNotification(notification.clone()); let (write_complete_tx, write_complete_rx) = oneshot::channel(); if let Err(err) = self .sender @@ -643,6 +683,15 @@ impl OutgoingMessageSender { } } +fn now_unix_timestamp_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or_default() +} + #[cfg(test)] mod tests { use std::time::Duration; @@ -898,6 +947,7 @@ mod tests { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: "item-1".to_string(), + started_at_ms: 0, approval_id: None, reason: None, network_approval_context: None, @@ -1011,11 +1061,7 @@ mod tests { connection_id: ConnectionId(9), request_id: RequestId::Integer(3), }; - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: "boom".to_string(), - data: None, - }; + let error = internal_error("boom"); outgoing.send_error(request_id.clone(), error.clone()).await; @@ -1139,11 +1185,7 @@ mod tests { )) .await; - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: "refresh failed".to_string(), - data: None, - }; + let error = internal_error("refresh failed"); outgoing .notify_client_error(request_id, error.clone()) @@ -1198,6 +1240,7 @@ mod tests { thread_id: thread_id.to_string(), turn_id: "turn-1".to_string(), item_id: "call-2".to_string(), + started_at_ms: 0, reason: None, grant_root: None, }, @@ -1253,11 +1296,7 @@ mod tests { }, )) .await; - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: "tracked request cancelled".to_string(), - data: None, - }; + let error = internal_error("tracked request cancelled"); outgoing .cancel_requests_for_thread(thread_id, Some(error.clone())) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs new file mode 100644 index 0000000000..d02aa6087b --- /dev/null +++ b/codex-rs/app-server/src/request_processors.rs @@ -0,0 +1,515 @@ +use crate::bespoke_event_handling::apply_bespoke_event_handling; +use crate::bespoke_event_handling::maybe_emit_hook_prompt_item_completed; +use crate::command_exec::CommandExecManager; +use crate::command_exec::StartCommandExecParams; +use crate::config_manager::ConfigManager; +use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE; +use crate::error_code::invalid_params; +use crate::models::supported_models; +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::ConnectionRequestId; +use crate::outgoing_message::OutgoingMessageSender; +use crate::outgoing_message::RequestContext; +use crate::outgoing_message::ThreadScopedOutgoingMessageSender; +use crate::skills_watcher::SkillsWatcher; +use crate::thread_status::ThreadWatchManager; +use crate::thread_status::resolve_thread_status; +use chrono::Duration as ChronoDuration; +use chrono::SecondsFormat; +use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AnalyticsJsonRpcError; +use codex_analytics::InputError; +use codex_analytics::TurnSteerRequestError; +use codex_app_server_protocol::Account; +use codex_app_server_protocol::AccountLoginCompletedNotification; +use codex_app_server_protocol::AccountUpdatedNotification; +use codex_app_server_protocol::AddCreditsNudgeCreditType; +use codex_app_server_protocol::AddCreditsNudgeEmailStatus; +use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::AppListUpdatedNotification; +use codex_app_server_protocol::AppSummary; +use codex_app_server_protocol::AppsListParams; +use codex_app_server_protocol::AppsListResponse; +use codex_app_server_protocol::AskForApproval; +use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::CancelLoginAccountParams; +use codex_app_server_protocol::CancelLoginAccountResponse; +use codex_app_server_protocol::CancelLoginAccountStatus; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ClientResponsePayload; +use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::CollaborationModeListParams; +use codex_app_server_protocol::CollaborationModeListResponse; +use codex_app_server_protocol::CommandExecParams; +use codex_app_server_protocol::CommandExecResizeParams; +use codex_app_server_protocol::CommandExecTerminateParams; +use codex_app_server_protocol::CommandExecWriteParams; +use codex_app_server_protocol::ConfigWarningNotification; +use codex_app_server_protocol::ConversationGitInfo; +use codex_app_server_protocol::ConversationSummary; +use codex_app_server_protocol::DeprecationNoticeNotification; +use codex_app_server_protocol::DynamicToolSpec as ApiDynamicToolSpec; +use codex_app_server_protocol::EnvironmentAddParams; +use codex_app_server_protocol::EnvironmentAddResponse; +use codex_app_server_protocol::ExperimentalFeature as ApiExperimentalFeature; +use codex_app_server_protocol::ExperimentalFeatureListParams; +use codex_app_server_protocol::ExperimentalFeatureListResponse; +use codex_app_server_protocol::ExperimentalFeatureStage as ApiExperimentalFeatureStage; +use codex_app_server_protocol::FeedbackUploadParams; +use codex_app_server_protocol::FeedbackUploadResponse; +use codex_app_server_protocol::GetAccountParams; +use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_app_server_protocol::GetAccountResponse; +use codex_app_server_protocol::GetAuthStatusParams; +use codex_app_server_protocol::GetAuthStatusResponse; +use codex_app_server_protocol::GetConversationSummaryParams; +use codex_app_server_protocol::GetConversationSummaryResponse; +use codex_app_server_protocol::GitDiffToRemoteParams; +use codex_app_server_protocol::GitDiffToRemoteResponse; +use codex_app_server_protocol::GitInfo as ApiGitInfo; +use codex_app_server_protocol::HookMetadata; +use codex_app_server_protocol::HooksListParams; +use codex_app_server_protocol::HooksListResponse; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::InitializeResponse; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::ListMcpServerStatusParams; +use codex_app_server_protocol::ListMcpServerStatusResponse; +use codex_app_server_protocol::LoginAccountParams; +use codex_app_server_protocol::LoginAccountResponse; +use codex_app_server_protocol::LoginApiKeyParams; +use codex_app_server_protocol::LogoutAccountResponse; +use codex_app_server_protocol::MarketplaceAddParams; +use codex_app_server_protocol::MarketplaceAddResponse; +use codex_app_server_protocol::MarketplaceInterface; +use codex_app_server_protocol::MarketplaceRemoveParams; +use codex_app_server_protocol::MarketplaceRemoveResponse; +use codex_app_server_protocol::MarketplaceUpgradeErrorInfo; +use codex_app_server_protocol::MarketplaceUpgradeParams; +use codex_app_server_protocol::MarketplaceUpgradeResponse; +use codex_app_server_protocol::McpResourceReadParams; +use codex_app_server_protocol::McpResourceReadResponse; +use codex_app_server_protocol::McpServerOauthLoginCompletedNotification; +use codex_app_server_protocol::McpServerOauthLoginParams; +use codex_app_server_protocol::McpServerOauthLoginResponse; +use codex_app_server_protocol::McpServerRefreshResponse; +use codex_app_server_protocol::McpServerStatus; +use codex_app_server_protocol::McpServerStatusDetail; +use codex_app_server_protocol::McpServerToolCallParams; +use codex_app_server_protocol::McpServerToolCallResponse; +use codex_app_server_protocol::MemoryResetResponse; +use codex_app_server_protocol::MockExperimentalMethodParams; +use codex_app_server_protocol::MockExperimentalMethodResponse; +use codex_app_server_protocol::ModelListParams; +use codex_app_server_protocol::ModelListResponse; +use codex_app_server_protocol::PermissionProfileModificationParams; +use codex_app_server_protocol::PermissionProfileSelectionParams; +use codex_app_server_protocol::PluginDetail; +use codex_app_server_protocol::PluginInstallParams; +use codex_app_server_protocol::PluginInstallResponse; +use codex_app_server_protocol::PluginInterface; +use codex_app_server_protocol::PluginListMarketplaceKind; +use codex_app_server_protocol::PluginListParams; +use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginMarketplaceEntry; +use codex_app_server_protocol::PluginReadParams; +use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginShareContext; +use codex_app_server_protocol::PluginShareDeleteParams; +use codex_app_server_protocol::PluginShareDeleteResponse; +use codex_app_server_protocol::PluginShareDiscoverability; +use codex_app_server_protocol::PluginShareListItem; +use codex_app_server_protocol::PluginShareListParams; +use codex_app_server_protocol::PluginShareListResponse; +use codex_app_server_protocol::PluginSharePrincipal; +use codex_app_server_protocol::PluginSharePrincipalType; +use codex_app_server_protocol::PluginShareSaveParams; +use codex_app_server_protocol::PluginShareSaveResponse; +use codex_app_server_protocol::PluginShareTarget; +use codex_app_server_protocol::PluginShareUpdateDiscoverability; +use codex_app_server_protocol::PluginShareUpdateTargetsParams; +use codex_app_server_protocol::PluginShareUpdateTargetsResponse; +use codex_app_server_protocol::PluginSkillReadParams; +use codex_app_server_protocol::PluginSkillReadResponse; +use codex_app_server_protocol::PluginSource; +use codex_app_server_protocol::PluginSummary; +use codex_app_server_protocol::PluginUninstallParams; +use codex_app_server_protocol::PluginUninstallResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery; +use codex_app_server_protocol::ReviewStartParams; +use codex_app_server_protocol::ReviewStartResponse; +use codex_app_server_protocol::ReviewTarget as ApiReviewTarget; +use codex_app_server_protocol::SandboxMode; +use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; +use codex_app_server_protocol::SendAddCreditsNudgeEmailResponse; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ServerRequestResolvedNotification; +use codex_app_server_protocol::SkillSummary; +use codex_app_server_protocol::SkillsConfigWriteParams; +use codex_app_server_protocol::SkillsConfigWriteResponse; +use codex_app_server_protocol::SkillsListParams; +use codex_app_server_protocol::SkillsListResponse; +use codex_app_server_protocol::SortDirection; +use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadApproveGuardianDeniedActionParams; +use codex_app_server_protocol::ThreadApproveGuardianDeniedActionResponse; +use codex_app_server_protocol::ThreadArchiveParams; +use codex_app_server_protocol::ThreadArchiveResponse; +use codex_app_server_protocol::ThreadArchivedNotification; +use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams; +use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse; +use codex_app_server_protocol::ThreadClosedNotification; +use codex_app_server_protocol::ThreadCompactStartParams; +use codex_app_server_protocol::ThreadCompactStartResponse; +use codex_app_server_protocol::ThreadDecrementElicitationParams; +use codex_app_server_protocol::ThreadDecrementElicitationResponse; +use codex_app_server_protocol::ThreadForkParams; +use codex_app_server_protocol::ThreadForkResponse; +use codex_app_server_protocol::ThreadGoal; +use codex_app_server_protocol::ThreadGoalClearParams; +use codex_app_server_protocol::ThreadGoalClearResponse; +use codex_app_server_protocol::ThreadGoalClearedNotification; +use codex_app_server_protocol::ThreadGoalGetParams; +use codex_app_server_protocol::ThreadGoalGetResponse; +use codex_app_server_protocol::ThreadGoalSetParams; +use codex_app_server_protocol::ThreadGoalSetResponse; +use codex_app_server_protocol::ThreadGoalStatus; +use codex_app_server_protocol::ThreadGoalUpdatedNotification; +use codex_app_server_protocol::ThreadHistoryBuilder; +use codex_app_server_protocol::ThreadIncrementElicitationParams; +use codex_app_server_protocol::ThreadIncrementElicitationResponse; +use codex_app_server_protocol::ThreadInjectItemsParams; +use codex_app_server_protocol::ThreadInjectItemsResponse; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadListCwdFilter; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadLoadedListParams; +use codex_app_server_protocol::ThreadLoadedListResponse; +use codex_app_server_protocol::ThreadMemoryModeSetParams; +use codex_app_server_protocol::ThreadMemoryModeSetResponse; +use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams; +use codex_app_server_protocol::ThreadMetadataUpdateParams; +use codex_app_server_protocol::ThreadMetadataUpdateResponse; +use codex_app_server_protocol::ThreadNameUpdatedNotification; +use codex_app_server_protocol::ThreadReadParams; +use codex_app_server_protocol::ThreadReadResponse; +use codex_app_server_protocol::ThreadRealtimeAppendAudioParams; +use codex_app_server_protocol::ThreadRealtimeAppendAudioResponse; +use codex_app_server_protocol::ThreadRealtimeAppendTextParams; +use codex_app_server_protocol::ThreadRealtimeAppendTextResponse; +use codex_app_server_protocol::ThreadRealtimeListVoicesResponse; +use codex_app_server_protocol::ThreadRealtimeStartParams; +use codex_app_server_protocol::ThreadRealtimeStartResponse; +use codex_app_server_protocol::ThreadRealtimeStartTransport; +use codex_app_server_protocol::ThreadRealtimeStopParams; +use codex_app_server_protocol::ThreadRealtimeStopResponse; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadRollbackParams; +use codex_app_server_protocol::ThreadSetNameParams; +use codex_app_server_protocol::ThreadSetNameResponse; +use codex_app_server_protocol::ThreadShellCommandParams; +use codex_app_server_protocol::ThreadShellCommandResponse; +use codex_app_server_protocol::ThreadSortKey; +use codex_app_server_protocol::ThreadSourceKind; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStartedNotification; +use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::ThreadTurnsItemsListParams; +use codex_app_server_protocol::ThreadTurnsListParams; +use codex_app_server_protocol::ThreadTurnsListResponse; +use codex_app_server_protocol::ThreadUnarchiveParams; +use codex_app_server_protocol::ThreadUnarchiveResponse; +use codex_app_server_protocol::ThreadUnarchivedNotification; +use codex_app_server_protocol::ThreadUnsubscribeParams; +use codex_app_server_protocol::ThreadUnsubscribeResponse; +use codex_app_server_protocol::ThreadUnsubscribeStatus; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnEnvironmentParams; +use codex_app_server_protocol::TurnError; +use codex_app_server_protocol::TurnInterruptParams; +use codex_app_server_protocol::TurnInterruptResponse; +use codex_app_server_protocol::TurnItemsView; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStatus; +use codex_app_server_protocol::TurnSteerParams; +use codex_app_server_protocol::TurnSteerResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_app_server_protocol::WindowsSandboxReadiness; +use codex_app_server_protocol::WindowsSandboxReadinessResponse; +use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification; +use codex_app_server_protocol::WindowsSandboxSetupMode; +use codex_app_server_protocol::WindowsSandboxSetupStartParams; +use codex_app_server_protocol::WindowsSandboxSetupStartResponse; +use codex_arg0::Arg0DispatchPaths; +use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType; +use codex_backend_client::Client as BackendClient; +use codex_chatgpt::connectors; +use codex_chatgpt::workspace_settings; +use codex_config::CloudRequirementsLoadError; +use codex_config::CloudRequirementsLoadErrorCode; +use codex_config::ConfigLayerStack; +use codex_config::loader::project_trust_key; +use codex_config::types::McpServerTransportConfig; +use codex_core::CodexThread; +use codex_core::CodexThreadTurnContextOverrides; +use codex_core::ExternalGoalPreviousStatus; +use codex_core::ExternalGoalSet; +use codex_core::ForkSnapshot; +use codex_core::NewThread; +#[cfg(test)] +use codex_core::SessionMeta; +use codex_core::StartThreadOptions; +use codex_core::SteerInputError; +use codex_core::ThreadConfigSnapshot; +use codex_core::ThreadManager; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; +use codex_core::config::NetworkProxyAuditMetadata; +use codex_core::config::edit::ConfigEdit; +use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::exec::ExecCapturePolicy; +use codex_core::exec::ExecExpiration; +use codex_core::exec::ExecParams; +use codex_core::exec_env::create_env; +use codex_core::path_utils; +#[cfg(test)] +use codex_core::read_head_for_summary; +use codex_core::sandboxing::SandboxPermissions; +use codex_core::windows_sandbox::WindowsSandboxLevelExt; +use codex_core::windows_sandbox::WindowsSandboxSetupMode as CoreWindowsSandboxSetupMode; +use codex_core::windows_sandbox::WindowsSandboxSetupRequest; +use codex_core::windows_sandbox::sandbox_setup_is_complete; +use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; +use codex_core_plugins::PluginInstallError as CorePluginInstallError; +use codex_core_plugins::PluginInstallRequest; +use codex_core_plugins::PluginLoadOutcome; +use codex_core_plugins::PluginReadRequest; +use codex_core_plugins::PluginUninstallError as CorePluginUninstallError; +use codex_core_plugins::loader::load_plugin_apps; +use codex_core_plugins::loader::load_plugin_mcp_servers; +use codex_core_plugins::loader::plugin_telemetry_metadata_from_root; +use codex_core_plugins::manifest::PluginManifestInterface; +use codex_core_plugins::marketplace::MarketplaceError; +use codex_core_plugins::marketplace::MarketplacePluginSource; +use codex_core_plugins::marketplace_add::MarketplaceAddError; +use codex_core_plugins::marketplace_add::MarketplaceAddRequest; +use codex_core_plugins::marketplace_add::add_marketplace as add_marketplace_to_codex_home; +use codex_core_plugins::marketplace_remove::MarketplaceRemoveError; +use codex_core_plugins::marketplace_remove::MarketplaceRemoveRequest as CoreMarketplaceRemoveRequest; +use codex_core_plugins::marketplace_remove::remove_marketplace; +use codex_core_plugins::remote::RemoteMarketplace; +use codex_core_plugins::remote::RemoteMarketplaceSource; +use codex_core_plugins::remote::RemotePluginCatalogError; +use codex_core_plugins::remote::RemotePluginDetail as RemoteCatalogPluginDetail; +use codex_core_plugins::remote::RemotePluginServiceConfig; +use codex_core_plugins::remote::RemotePluginShareContext as RemoteCatalogPluginShareContext; +use codex_core_plugins::remote::RemotePluginShareSummary as RemoteCatalogPluginShareSummary; +use codex_core_plugins::remote::RemotePluginSummary as RemoteCatalogPluginSummary; +use codex_exec_server::EnvironmentManager; +use codex_exec_server::LOCAL_FS; +use codex_features::FEATURES; +use codex_features::Feature; +use codex_features::Stage; +use codex_feedback::CodexFeedback; +use codex_feedback::FeedbackAttachmentPath; +use codex_feedback::FeedbackUploadOptions; +use codex_git_utils::git_diff_to_remote; +use codex_git_utils::resolve_root_git_project_for_trust; +use codex_login::AuthManager; +use codex_login::CLIENT_ID; +use codex_login::CodexAuth; +use codex_login::ServerOptions as LoginServerOptions; +use codex_login::ShutdownHandle; +use codex_login::auth::login_with_chatgpt_auth_tokens; +use codex_login::complete_device_code_login; +use codex_login::login_with_api_key; +use codex_login::request_device_code; +use codex_login::run_login_server; +use codex_mcp::McpRuntimeEnvironment; +use codex_mcp::McpServerStatusSnapshot; +use codex_mcp::McpSnapshotDetail; +use codex_mcp::collect_mcp_server_status_snapshot_with_detail; +use codex_mcp::discover_supported_scopes; +use codex_mcp::effective_mcp_servers; +use codex_mcp::read_mcp_resource as read_mcp_resource_without_thread; +use codex_mcp::resolve_oauth_scopes; +use codex_memories_write::clear_memory_roots_contents; +use codex_model_provider::ProviderAccountError; +use codex_model_provider::create_model_provider; +use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; +use codex_protocol::ThreadId; +use codex_protocol::config_types::CollaborationMode; +use codex_protocol::config_types::ForcedLoginMethod; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::TrustLevel; +use codex_protocol::config_types::WindowsSandboxLevel; +use codex_protocol::dynamic_tools::DynamicToolSpec as CoreDynamicToolSpec; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CodexResult; +#[cfg(test)] +use codex_protocol::items::TurnItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::protocol::AgentStatus; +use codex_protocol::protocol::ConversationAudioParams; +use codex_protocol::protocol::ConversationStartParams; +use codex_protocol::protocol::ConversationStartTransport; +use codex_protocol::protocol::ConversationTextParams; +use codex_protocol::protocol::EventMsg; +#[cfg(test)] +use codex_protocol::protocol::GitInfo as CoreGitInfo; +use codex_protocol::protocol::InitialHistory; +use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; +use codex_protocol::protocol::RealtimeVoicesList; +use codex_protocol::protocol::ResumedHistory; +use codex_protocol::protocol::ReviewDelivery as CoreReviewDelivery; +use codex_protocol::protocol::ReviewRequest; +use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SessionConfiguredEvent; +#[cfg(test)] +use codex_protocol::protocol::SessionMetaLine; +use codex_protocol::protocol::TurnEnvironmentSelection; +use codex_protocol::protocol::USER_MESSAGE_BEGIN; +use codex_protocol::protocol::W3cTraceContext; +use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; +use codex_protocol::user_input::UserInput as CoreInputItem; +use codex_rmcp_client::perform_oauth_login_return_url; +use codex_rollout::EventPersistenceMode; +use codex_rollout::is_persisted_rollout_item; +use codex_rollout::state_db::StateDbHandle; +use codex_rollout::state_db::reconcile_rollout; +use codex_state::ThreadMetadata; +use codex_state::log_db::LogDbLayer; +use codex_thread_store::ArchiveThreadParams as StoreArchiveThreadParams; +use codex_thread_store::GitInfoPatch as StoreGitInfoPatch; +use codex_thread_store::ListThreadsParams as StoreListThreadsParams; +use codex_thread_store::LocalThreadStore; +use codex_thread_store::ReadThreadByRolloutPathParams as StoreReadThreadByRolloutPathParams; +use codex_thread_store::ReadThreadParams as StoreReadThreadParams; +use codex_thread_store::SortDirection as StoreSortDirection; +use codex_thread_store::StoredThread; +use codex_thread_store::ThreadMetadataPatch as StoreThreadMetadataPatch; +use codex_thread_store::ThreadSortKey as StoreThreadSortKey; +use codex_thread_store::ThreadStore; +use codex_thread_store::ThreadStoreError; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; +use std::collections::HashMap; +use std::collections::HashSet; +use std::io::Error as IoError; +use std::path::Path; +use std::path::PathBuf; +use std::result::Result; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; +use tokio::sync::Mutex; +use tokio::sync::Semaphore; +use tokio::sync::SemaphorePermit; +use tokio::sync::broadcast; +use tokio::sync::oneshot; +use tokio::sync::watch; +use tokio_util::sync::CancellationToken; +use tokio_util::task::TaskTracker; +use toml::Value as TomlValue; +use tracing::Instrument; +use tracing::error; +use tracing::info; +use tracing::warn; +use uuid::Uuid; + +#[cfg(test)] +use codex_app_server_protocol::ServerRequest; + +mod account_processor; +mod apps_processor; +mod catalog_processor; +mod command_exec_processor; +mod config_processor; +mod environment_processor; +mod external_agent_config_processor; +mod feedback_processor; +mod fs_processor; +mod git_processor; +mod initialize_processor; +mod marketplace_processor; +mod mcp_processor; +mod plugins; +mod process_exec_processor; +mod search; +mod thread_processor; +mod token_usage_replay; +mod turn_processor; +mod windows_sandbox_processor; + +pub(crate) use account_processor::AccountRequestProcessor; +pub(crate) use apps_processor::AppsRequestProcessor; +pub(crate) use catalog_processor::CatalogRequestProcessor; +pub(crate) use command_exec_processor::CommandExecRequestProcessor; +pub(crate) use config_processor::ConfigRequestProcessor; +pub(crate) use environment_processor::EnvironmentRequestProcessor; +pub(crate) use external_agent_config_processor::ExternalAgentConfigRequestProcessor; +pub(crate) use feedback_processor::FeedbackRequestProcessor; +pub(crate) use fs_processor::FsRequestProcessor; +pub(crate) use git_processor::GitRequestProcessor; +pub(crate) use initialize_processor::InitializeRequestProcessor; +pub(crate) use marketplace_processor::MarketplaceRequestProcessor; +pub(crate) use mcp_processor::McpRequestProcessor; +pub(crate) use plugins::PluginRequestProcessor; +pub(crate) use process_exec_processor::ProcessExecRequestProcessor; +pub(crate) use search::SearchRequestProcessor; +pub(crate) use thread_goal_processor::ThreadGoalRequestProcessor; +pub(crate) use thread_processor::ThreadRequestProcessor; +pub(crate) use turn_processor::TurnRequestProcessor; +pub(crate) use windows_sandbox_processor::WindowsSandboxRequestProcessor; + +use crate::error_code::internal_error; +use crate::error_code::invalid_request; +use crate::filters::compute_source_filters; +use crate::filters::source_kind_matches; +use crate::thread_state::ConnectionCapabilities; +use crate::thread_state::ThreadListenerCommand; +use crate::thread_state::ThreadState; +use crate::thread_state::ThreadStateManager; +use token_usage_replay::latest_token_usage_turn_id_from_rollout_items; +use token_usage_replay::send_thread_token_usage_update_to_connection; + +mod config_errors; +mod request_errors; +mod thread_goal_processor; +mod thread_lifecycle; +mod thread_resume_redaction; +mod thread_summary; + +use self::config_errors::*; +use self::request_errors::*; +use self::thread_goal_processor::api_thread_goal_from_state; +use self::thread_lifecycle::*; +use self::thread_resume_redaction::*; +use self::thread_summary::*; + +pub(crate) use self::thread_lifecycle::populate_thread_turns_from_history; +pub(crate) use self::thread_processor::thread_from_stored_thread; +#[cfg(test)] +pub(crate) use self::thread_summary::read_summary_from_rollout; +#[cfg(test)] +pub(crate) use self::thread_summary::summary_to_thread; + +pub(crate) fn build_api_turns_from_rollout_items(items: &[RolloutItem]) -> Vec { + let mut builder = ThreadHistoryBuilder::new(); + for item in items { + if is_persisted_rollout_item(item, EventPersistenceMode::Limited) { + builder.handle_rollout_item(item); + } + } + builder.finish() +} diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs new file mode 100644 index 0000000000..c73d6700e7 --- /dev/null +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -0,0 +1,952 @@ +use super::*; + +// Duration before a browser ChatGPT login attempt is abandoned. +const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); +const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; + +enum ActiveLogin { + Browser { + shutdown_handle: ShutdownHandle, + login_id: Uuid, + }, + DeviceCode { + cancel: CancellationToken, + login_id: Uuid, + }, +} + +impl ActiveLogin { + fn login_id(&self) -> Uuid { + match self { + ActiveLogin::Browser { login_id, .. } | ActiveLogin::DeviceCode { login_id, .. } => { + *login_id + } + } + } + + fn cancel(&self) { + match self { + ActiveLogin::Browser { + shutdown_handle, .. + } => shutdown_handle.shutdown(), + ActiveLogin::DeviceCode { cancel, .. } => cancel.cancel(), + } + } +} + +#[derive(Clone, Copy, Debug)] +enum CancelLoginError { + NotFound, +} + +enum RefreshTokenRequestOutcome { + NotAttemptedOrSucceeded, + FailedTransiently, + FailedPermanently, +} + +impl Drop for ActiveLogin { + fn drop(&mut self) { + self.cancel(); + } +} + +#[derive(Clone)] +pub(crate) struct AccountRequestProcessor { + auth_manager: Arc, + thread_manager: Arc, + outgoing: Arc, + config: Arc, + config_manager: ConfigManager, + active_login: Arc>>, +} + +impl AccountRequestProcessor { + pub(crate) fn new( + auth_manager: Arc, + thread_manager: Arc, + outgoing: Arc, + config: Arc, + config_manager: ConfigManager, + ) -> Self { + Self { + auth_manager, + thread_manager, + outgoing, + config, + config_manager, + active_login: Arc::new(Mutex::new(None)), + } + } + + pub(crate) async fn login_account( + &self, + request_id: ConnectionRequestId, + params: LoginAccountParams, + ) -> Result, JSONRPCErrorError> { + self.login_v2(request_id, params).await.map(|()| None) + } + + pub(crate) async fn logout_account( + &self, + request_id: ConnectionRequestId, + ) -> Result, JSONRPCErrorError> { + self.logout_v2(request_id).await.map(|()| None) + } + + pub(crate) async fn cancel_login_account( + &self, + params: CancelLoginAccountParams, + ) -> Result, JSONRPCErrorError> { + self.cancel_login_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn get_account( + &self, + params: GetAccountParams, + ) -> Result, JSONRPCErrorError> { + self.get_account_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn get_auth_status( + &self, + params: GetAuthStatusParams, + ) -> Result, JSONRPCErrorError> { + self.get_auth_status_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn get_account_rate_limits( + &self, + ) -> Result, JSONRPCErrorError> { + self.get_account_rate_limits_response() + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn send_add_credits_nudge_email( + &self, + params: SendAddCreditsNudgeEmailParams, + ) -> Result, JSONRPCErrorError> { + self.send_add_credits_nudge_email_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn cancel_active_login(&self) { + let mut guard = self.active_login.lock().await; + if let Some(active_login) = guard.take() { + drop(active_login); + } + } + + pub(crate) fn clear_external_auth(&self) { + self.auth_manager.clear_external_auth(); + } + + fn current_account_updated_notification(&self) -> AccountUpdatedNotification { + let auth = self.auth_manager.auth_cached(); + AccountUpdatedNotification { + auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode), + plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type), + } + } + + async fn maybe_refresh_remote_installed_plugins_cache_for_current_config( + config_manager: &ConfigManager, + thread_manager: &Arc, + auth: Option, + ) { + match config_manager + .load_latest_config(/*fallback_cwd*/ None) + .await + { + Ok(config) => { + let refresh_thread_manager = Arc::clone(thread_manager); + let refresh_config_manager = config_manager.clone(); + thread_manager + .plugins_manager() + .maybe_start_remote_installed_plugins_cache_refresh( + &config.plugins_config_input(), + auth, + Some(Arc::new(move || { + Self::spawn_effective_plugins_changed_task( + Arc::clone(&refresh_thread_manager), + refresh_config_manager.clone(), + ); + })), + ); + } + Err(err) => { + warn!( + "failed to reload config after account changed, skipping remote installed plugins cache refresh: {err}" + ); + } + } + } + + fn spawn_effective_plugins_changed_task( + thread_manager: Arc, + config_manager: ConfigManager, + ) { + tokio::spawn(async move { + thread_manager.plugins_manager().clear_cache(); + thread_manager.skills_manager().clear_cache(); + if thread_manager.list_thread_ids().await.is_empty() { + return; + } + crate::mcp_refresh::queue_best_effort_refresh(&thread_manager, &config_manager).await; + }); + } + + async fn login_v2( + &self, + request_id: ConnectionRequestId, + params: LoginAccountParams, + ) -> Result<(), JSONRPCErrorError> { + match params { + LoginAccountParams::ApiKey { api_key } => { + self.login_api_key_v2(request_id, LoginApiKeyParams { api_key }) + .await; + } + LoginAccountParams::Chatgpt { + codex_streamlined_login, + } => { + self.login_chatgpt_v2(request_id, codex_streamlined_login) + .await; + } + LoginAccountParams::ChatgptDeviceCode => { + self.login_chatgpt_device_code_v2(request_id).await; + } + LoginAccountParams::ChatgptAuthTokens { + access_token, + chatgpt_account_id, + chatgpt_plan_type, + } => { + self.login_chatgpt_auth_tokens( + request_id, + access_token, + chatgpt_account_id, + chatgpt_plan_type, + ) + .await; + } + } + Ok(()) + } + + fn external_auth_active_error(&self) -> JSONRPCErrorError { + invalid_request( + "External auth is active. Use account/login/start (chatgptAuthTokens) to update it or account/logout to clear it.", + ) + } + + async fn login_api_key_common( + &self, + params: &LoginApiKeyParams, + ) -> std::result::Result<(), JSONRPCErrorError> { + if self.auth_manager.is_external_chatgpt_auth_active() { + return Err(self.external_auth_active_error()); + } + + if matches!( + self.config.forced_login_method, + Some(ForcedLoginMethod::Chatgpt) + ) { + return Err(invalid_request( + "API key login is disabled. Use ChatGPT login instead.", + )); + } + + // Cancel any active login attempt. + { + let mut guard = self.active_login.lock().await; + if let Some(active) = guard.take() { + drop(active); + } + } + + match login_with_api_key( + &self.config.codex_home, + ¶ms.api_key, + self.config.cli_auth_credentials_store_mode, + ) { + Ok(()) => { + self.auth_manager.reload().await; + Ok(()) + } + Err(err) => Err(internal_error(format!("failed to save api key: {err}"))), + } + } + + async fn login_api_key_v2(&self, request_id: ConnectionRequestId, params: LoginApiKeyParams) { + let result = self + .login_api_key_common(¶ms) + .await + .map(|()| LoginAccountResponse::ApiKey {}); + let logged_in = result.is_ok(); + self.outgoing.send_result(request_id, result).await; + + if logged_in { + self.send_login_success_notifications(/*login_id*/ None) + .await; + } + } + + // Build options for a ChatGPT login attempt; performs validation. + async fn login_chatgpt_common( + &self, + codex_streamlined_login: bool, + ) -> std::result::Result { + let config = self.config.as_ref(); + + if self.auth_manager.is_external_chatgpt_auth_active() { + return Err(self.external_auth_active_error()); + } + + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { + return Err(invalid_request( + "ChatGPT login is disabled. Use API key login instead.", + )); + } + + let opts = LoginServerOptions { + open_browser: false, + codex_streamlined_login, + ..LoginServerOptions::new( + config.codex_home.to_path_buf(), + CLIENT_ID.to_string(), + config.forced_chatgpt_workspace_id.clone(), + config.cli_auth_credentials_store_mode, + ) + }; + #[cfg(debug_assertions)] + let opts = { + let mut opts = opts; + if let Ok(issuer) = std::env::var(LOGIN_ISSUER_OVERRIDE_ENV_VAR) + && !issuer.trim().is_empty() + { + opts.issuer = issuer; + } + opts + }; + + Ok(opts) + } + + fn login_chatgpt_device_code_start_error(err: IoError) -> JSONRPCErrorError { + let is_not_found = err.kind() == std::io::ErrorKind::NotFound; + if is_not_found { + invalid_request(err.to_string()) + } else { + internal_error(format!("failed to request device code: {err}")) + } + } + + async fn login_chatgpt_v2( + &self, + request_id: ConnectionRequestId, + codex_streamlined_login: bool, + ) { + let result = self.login_chatgpt_response(codex_streamlined_login).await; + self.outgoing.send_result(request_id, result).await; + } + + async fn login_chatgpt_response( + &self, + codex_streamlined_login: bool, + ) -> Result { + let opts = self.login_chatgpt_common(codex_streamlined_login).await?; + let server = run_login_server(opts) + .map_err(|err| internal_error(format!("failed to start login server: {err}")))?; + let login_id = Uuid::new_v4(); + let shutdown_handle = server.cancel_handle(); + + // Replace active login if present. + { + let mut guard = self.active_login.lock().await; + if let Some(existing) = guard.take() { + drop(existing); + } + *guard = Some(ActiveLogin::Browser { + shutdown_handle: shutdown_handle.clone(), + login_id, + }); + } + + let outgoing_clone = self.outgoing.clone(); + let config_manager = self.config_manager.clone(); + let thread_manager = Arc::clone(&self.thread_manager); + let chatgpt_base_url = self.config.chatgpt_base_url.clone(); + let active_login = self.active_login.clone(); + let auth_url = server.auth_url.clone(); + tokio::spawn(async move { + let (success, error_msg) = match tokio::time::timeout( + LOGIN_CHATGPT_TIMEOUT, + server.block_until_done(), + ) + .await + { + Ok(Ok(())) => (true, None), + Ok(Err(err)) => (false, Some(format!("Login server error: {err}"))), + Err(_elapsed) => { + shutdown_handle.shutdown(); + (false, Some("Login timed out".to_string())) + } + }; + + Self::send_chatgpt_login_completion_notifications( + &outgoing_clone, + config_manager, + thread_manager, + chatgpt_base_url, + login_id, + success, + error_msg, + ) + .await; + + // Clear the active login if it matches this attempt. It may have been replaced or cancelled. + let mut guard = active_login.lock().await; + if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { + *guard = None; + } + }); + + Ok(LoginAccountResponse::Chatgpt { + login_id: login_id.to_string(), + auth_url, + }) + } + + async fn login_chatgpt_device_code_v2(&self, request_id: ConnectionRequestId) { + let result = self.login_chatgpt_device_code_response().await; + self.outgoing.send_result(request_id, result).await; + } + + async fn login_chatgpt_device_code_response( + &self, + ) -> Result { + let opts = self + .login_chatgpt_common(/*codex_streamlined_login*/ false) + .await?; + let device_code = request_device_code(&opts) + .await + .map_err(Self::login_chatgpt_device_code_start_error)?; + let login_id = Uuid::new_v4(); + let cancel = CancellationToken::new(); + + { + let mut guard = self.active_login.lock().await; + if let Some(existing) = guard.take() { + drop(existing); + } + *guard = Some(ActiveLogin::DeviceCode { + cancel: cancel.clone(), + login_id, + }); + } + + let verification_url = device_code.verification_url.clone(); + let user_code = device_code.user_code.clone(); + + let outgoing_clone = self.outgoing.clone(); + let config_manager = self.config_manager.clone(); + let thread_manager = Arc::clone(&self.thread_manager); + let chatgpt_base_url = self.config.chatgpt_base_url.clone(); + let active_login = self.active_login.clone(); + tokio::spawn(async move { + let (success, error_msg) = tokio::select! { + _ = cancel.cancelled() => { + (false, Some("Login was not completed".to_string())) + } + r = complete_device_code_login(opts, device_code) => { + match r { + Ok(()) => (true, None), + Err(err) => (false, Some(err.to_string())), + } + } + }; + + Self::send_chatgpt_login_completion_notifications( + &outgoing_clone, + config_manager, + thread_manager, + chatgpt_base_url, + login_id, + success, + error_msg, + ) + .await; + + let mut guard = active_login.lock().await; + if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { + *guard = None; + } + }); + + Ok(LoginAccountResponse::ChatgptDeviceCode { + login_id: login_id.to_string(), + verification_url, + user_code, + }) + } + + async fn cancel_login_chatgpt_common( + &self, + login_id: Uuid, + ) -> std::result::Result<(), CancelLoginError> { + let mut guard = self.active_login.lock().await; + if guard.as_ref().map(ActiveLogin::login_id) == Some(login_id) { + if let Some(active) = guard.take() { + drop(active); + } + Ok(()) + } else { + Err(CancelLoginError::NotFound) + } + } + + async fn cancel_login_response( + &self, + params: CancelLoginAccountParams, + ) -> Result { + let login_id = params.login_id; + let uuid = Uuid::parse_str(&login_id) + .map_err(|_| invalid_request(format!("invalid login id: {login_id}")))?; + let status = match self.cancel_login_chatgpt_common(uuid).await { + Ok(()) => CancelLoginAccountStatus::Canceled, + Err(CancelLoginError::NotFound) => CancelLoginAccountStatus::NotFound, + }; + Ok(CancelLoginAccountResponse { status }) + } + + async fn login_chatgpt_auth_tokens( + &self, + request_id: ConnectionRequestId, + access_token: String, + chatgpt_account_id: String, + chatgpt_plan_type: Option, + ) { + let result = self + .login_chatgpt_auth_tokens_response(access_token, chatgpt_account_id, chatgpt_plan_type) + .await; + let logged_in = result.is_ok(); + self.outgoing.send_result(request_id, result).await; + + if logged_in { + self.send_login_success_notifications(/*login_id*/ None) + .await; + } + } + + async fn login_chatgpt_auth_tokens_response( + &self, + access_token: String, + chatgpt_account_id: String, + chatgpt_plan_type: Option, + ) -> Result { + if matches!( + self.config.forced_login_method, + Some(ForcedLoginMethod::Api) + ) { + return Err(invalid_request( + "External ChatGPT auth is disabled. Use API key login instead.", + )); + } + + // Cancel any active login attempt to avoid persisting managed auth state. + { + let mut guard = self.active_login.lock().await; + if let Some(active) = guard.take() { + drop(active); + } + } + + if let Some(expected_workspace) = self.config.forced_chatgpt_workspace_id.as_deref() + && chatgpt_account_id != expected_workspace + { + return Err(invalid_request(format!( + "External auth must use workspace {expected_workspace}, but received {chatgpt_account_id:?}." + ))); + } + + login_with_chatgpt_auth_tokens( + &self.config.codex_home, + &access_token, + &chatgpt_account_id, + chatgpt_plan_type.as_deref(), + ) + .map_err(|err| internal_error(format!("failed to set external auth: {err}")))?; + self.auth_manager.reload().await; + self.config_manager.replace_cloud_requirements_loader( + self.auth_manager.clone(), + self.config.chatgpt_base_url.clone(), + ); + self.config_manager + .sync_default_client_residency_requirement() + .await; + + Ok(LoginAccountResponse::ChatgptAuthTokens {}) + } + + async fn send_login_success_notifications(&self, login_id: Option) { + Self::maybe_refresh_remote_installed_plugins_cache_for_current_config( + &self.config_manager, + &self.thread_manager, + self.auth_manager.auth_cached(), + ) + .await; + + let payload_login_completed = AccountLoginCompletedNotification { + login_id: login_id.map(|id| id.to_string()), + success: true, + error: None, + }; + self.outgoing + .send_server_notification(ServerNotification::AccountLoginCompleted( + payload_login_completed, + )) + .await; + + self.outgoing + .send_server_notification(ServerNotification::AccountUpdated( + self.current_account_updated_notification(), + )) + .await; + } + + async fn send_chatgpt_login_completion_notifications( + outgoing: &OutgoingMessageSender, + config_manager: ConfigManager, + thread_manager: Arc, + chatgpt_base_url: String, + login_id: Uuid, + success: bool, + error_msg: Option, + ) { + let payload_v2 = AccountLoginCompletedNotification { + login_id: Some(login_id.to_string()), + success, + error: error_msg, + }; + outgoing + .send_server_notification(ServerNotification::AccountLoginCompleted(payload_v2)) + .await; + + if success { + let auth_manager = thread_manager.auth_manager(); + auth_manager.reload().await; + config_manager + .replace_cloud_requirements_loader(auth_manager.clone(), chatgpt_base_url); + config_manager + .sync_default_client_residency_requirement() + .await; + + let auth = auth_manager.auth_cached(); + Self::maybe_refresh_remote_installed_plugins_cache_for_current_config( + &config_manager, + &thread_manager, + auth.clone(), + ) + .await; + let payload_v2 = AccountUpdatedNotification { + auth_mode: auth.as_ref().map(CodexAuth::api_auth_mode), + plan_type: auth.as_ref().and_then(CodexAuth::account_plan_type), + }; + outgoing + .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) + .await; + } + } + + async fn logout_common(&self) -> std::result::Result, JSONRPCErrorError> { + // Cancel any active login attempt. + { + let mut guard = self.active_login.lock().await; + if let Some(active) = guard.take() { + drop(active); + } + } + + match self.auth_manager.logout_with_revoke().await { + Ok(_) => {} + Err(err) => { + return Err(internal_error(format!("logout failed: {err}"))); + } + } + + Self::maybe_refresh_remote_installed_plugins_cache_for_current_config( + &self.config_manager, + &self.thread_manager, + self.auth_manager.auth_cached(), + ) + .await; + + // Reflect the current auth method after logout (likely None). + Ok(self + .auth_manager + .auth_cached() + .as_ref() + .map(CodexAuth::api_auth_mode)) + } + + async fn logout_v2(&self, request_id: ConnectionRequestId) -> Result<(), JSONRPCErrorError> { + let result = self.logout_common().await; + let account_updated = + result + .as_ref() + .ok() + .cloned() + .map(|auth_mode| AccountUpdatedNotification { + auth_mode, + plan_type: None, + }); + self.outgoing + .send_result(request_id, result.map(|_| LogoutAccountResponse {})) + .await; + + if let Some(payload) = account_updated { + self.outgoing + .send_server_notification(ServerNotification::AccountUpdated(payload)) + .await; + } + Ok(()) + } + + async fn refresh_token_if_requested(&self, do_refresh: bool) -> RefreshTokenRequestOutcome { + if self.auth_manager.is_external_chatgpt_auth_active() { + return RefreshTokenRequestOutcome::NotAttemptedOrSucceeded; + } + if do_refresh && let Err(err) = self.auth_manager.refresh_token().await { + let failed_reason = err.failed_reason(); + if failed_reason.is_none() { + tracing::warn!("failed to refresh token while getting account: {err}"); + return RefreshTokenRequestOutcome::FailedTransiently; + } + return RefreshTokenRequestOutcome::FailedPermanently; + } + RefreshTokenRequestOutcome::NotAttemptedOrSucceeded + } + + async fn get_auth_status_response( + &self, + params: GetAuthStatusParams, + ) -> Result { + let include_token = params.include_token.unwrap_or(false); + let do_refresh = params.refresh_token.unwrap_or(false); + + self.refresh_token_if_requested(do_refresh).await; + + // Determine whether auth is required based on the active model provider. + // If a custom provider is configured with `requires_openai_auth == false`, + // then no auth step is required; otherwise, default to requiring auth. + let requires_openai_auth = self.config.model_provider.requires_openai_auth; + + let response = if !requires_openai_auth { + GetAuthStatusResponse { + auth_method: None, + auth_token: None, + requires_openai_auth: Some(false), + } + } else { + let auth = if do_refresh { + self.auth_manager.auth_cached() + } else { + self.auth_manager.auth().await + }; + match auth { + Some(auth) => { + let permanent_refresh_failure = + self.auth_manager.refresh_failure_for_auth(&auth).is_some(); + let auth_mode = auth.api_auth_mode(); + let (reported_auth_method, token_opt) = + if matches!(auth, CodexAuth::AgentIdentity(_)) + || include_token && permanent_refresh_failure + { + (Some(auth_mode), None) + } else { + match auth.get_token() { + Ok(token) if !token.is_empty() => { + let tok = if include_token { Some(token) } else { None }; + (Some(auth_mode), tok) + } + Ok(_) => (None, None), + Err(err) => { + tracing::warn!("failed to get token for auth status: {err}"); + (None, None) + } + } + }; + GetAuthStatusResponse { + auth_method: reported_auth_method, + auth_token: token_opt, + requires_openai_auth: Some(true), + } + } + None => GetAuthStatusResponse { + auth_method: None, + auth_token: None, + requires_openai_auth: Some(true), + }, + } + }; + + Ok(response) + } + + async fn get_account_response( + &self, + params: GetAccountParams, + ) -> Result { + let do_refresh = params.refresh_token; + + self.refresh_token_if_requested(do_refresh).await; + + let provider = create_model_provider( + self.config.model_provider.clone(), + Some(self.auth_manager.clone()), + ); + let account_state = match provider.account_state() { + Ok(account_state) => account_state, + Err(ProviderAccountError::MissingChatgptAccountDetails) => { + return Err(invalid_request( + "email and plan type are required for chatgpt authentication", + )); + } + }; + let account = account_state.account.map(Account::from); + + Ok(GetAccountResponse { + account, + requires_openai_auth: account_state.requires_openai_auth, + }) + } + + async fn get_account_rate_limits_response( + &self, + ) -> Result { + self.fetch_account_rate_limits() + .await + .map( + |(rate_limits, rate_limits_by_limit_id)| GetAccountRateLimitsResponse { + rate_limits: rate_limits.into(), + rate_limits_by_limit_id: Some( + rate_limits_by_limit_id + .into_iter() + .map(|(limit_id, snapshot)| (limit_id, snapshot.into())) + .collect(), + ), + }, + ) + } + + async fn send_add_credits_nudge_email_response( + &self, + params: SendAddCreditsNudgeEmailParams, + ) -> Result { + self.send_add_credits_nudge_email_inner(params) + .await + .map(|status| SendAddCreditsNudgeEmailResponse { status }) + } + + async fn send_add_credits_nudge_email_inner( + &self, + params: SendAddCreditsNudgeEmailParams, + ) -> Result { + let Some(auth) = self.auth_manager.auth().await else { + return Err(invalid_request( + "codex account authentication required to notify workspace owner", + )); + }; + + if !auth.uses_codex_backend() { + return Err(invalid_request( + "chatgpt authentication required to notify workspace owner", + )); + } + + let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) + .map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?; + + match client + .send_add_credits_nudge_email(Self::backend_credit_type(params.credit_type)) + .await + { + Ok(()) => Ok(AddCreditsNudgeEmailStatus::Sent), + Err(err) if err.status().is_some_and(|status| status.as_u16() == 429) => { + Ok(AddCreditsNudgeEmailStatus::CooldownActive) + } + Err(err) => Err(internal_error(format!( + "failed to notify workspace owner: {err}" + ))), + } + } + + fn backend_credit_type(value: AddCreditsNudgeCreditType) -> BackendAddCreditsNudgeCreditType { + match value { + AddCreditsNudgeCreditType::Credits => BackendAddCreditsNudgeCreditType::Credits, + AddCreditsNudgeCreditType::UsageLimit => BackendAddCreditsNudgeCreditType::UsageLimit, + } + } + + async fn fetch_account_rate_limits( + &self, + ) -> Result< + ( + CoreRateLimitSnapshot, + HashMap, + ), + JSONRPCErrorError, + > { + let Some(auth) = self.auth_manager.auth().await else { + return Err(invalid_request( + "codex account authentication required to read rate limits", + )); + }; + + if !auth.uses_codex_backend() { + return Err(invalid_request( + "chatgpt authentication required to read rate limits", + )); + } + + let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) + .map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?; + + let snapshots = client + .get_rate_limits_many() + .await + .map_err(|err| internal_error(format!("failed to fetch codex rate limits: {err}")))?; + if snapshots.is_empty() { + return Err(internal_error( + "failed to fetch codex rate limits: no snapshots returned", + )); + } + + let rate_limits_by_limit_id: HashMap = snapshots + .iter() + .cloned() + .map(|snapshot| { + let limit_id = snapshot + .limit_id + .clone() + .unwrap_or_else(|| "codex".to_string()); + (limit_id, snapshot) + }) + .collect(); + + let primary = snapshots + .iter() + .find(|snapshot| snapshot.limit_id.as_deref() == Some("codex")) + .cloned() + .unwrap_or_else(|| snapshots[0].clone()); + + Ok((primary, rate_limits_by_limit_id)) + } +} diff --git a/codex-rs/app-server/src/request_processors/apps_processor.rs b/codex-rs/app-server/src/request_processors/apps_processor.rs new file mode 100644 index 0000000000..da2956dbab --- /dev/null +++ b/codex-rs/app-server/src/request_processors/apps_processor.rs @@ -0,0 +1,337 @@ +use super::*; + +#[derive(Clone)] +pub(crate) struct AppsRequestProcessor { + auth_manager: Arc, + thread_manager: Arc, + outgoing: Arc, + config_manager: ConfigManager, + workspace_settings_cache: Arc, +} + +impl AppsRequestProcessor { + pub(crate) fn new( + auth_manager: Arc, + thread_manager: Arc, + outgoing: Arc, + config_manager: ConfigManager, + workspace_settings_cache: Arc, + ) -> Self { + Self { + auth_manager, + thread_manager, + outgoing, + config_manager, + workspace_settings_cache, + } + } + + pub(crate) async fn apps_list( + &self, + request_id: &ConnectionRequestId, + params: AppsListParams, + ) -> Result, JSONRPCErrorError> { + self.apps_list_inner(request_id, params) + .await + .map(|response| response.map(Into::into)) + } + + async fn apps_list_inner( + &self, + request_id: &ConnectionRequestId, + params: AppsListParams, + ) -> Result, JSONRPCErrorError> { + let mut config = self.load_latest_config(/*fallback_cwd*/ None).await?; + + if let Some(thread_id) = params.thread_id.as_deref() { + let (_, thread) = self.load_thread(thread_id).await?; + + let _ = config + .features + .set_enabled(Feature::Apps, thread.enabled(Feature::Apps)); + } + + let auth = self.auth_manager.auth().await; + if !config + .features + .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend)) + { + return Ok(Some(AppsListResponse { + data: Vec::new(), + next_cursor: None, + })); + } + + if !self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await + { + return Ok(Some(AppsListResponse { + data: Vec::new(), + next_cursor: None, + })); + } + + let request = request_id.clone(); + let outgoing = Arc::clone(&self.outgoing); + let environment_manager = self.thread_manager.environment_manager(); + tokio::spawn(async move { + Self::apps_list_task(outgoing, request, params, config, environment_manager).await; + }); + Ok(None) + } + + async fn apps_list_task( + outgoing: Arc, + request_id: ConnectionRequestId, + params: AppsListParams, + config: Config, + environment_manager: Arc, + ) { + let result = Self::apps_list_response(&outgoing, params, config, environment_manager).await; + outgoing.send_result(request_id, result).await; + } + + async fn apps_list_response( + outgoing: &Arc, + params: AppsListParams, + config: Config, + environment_manager: Arc, + ) -> Result { + let AppsListParams { + cursor, + limit, + thread_id: _, + force_refetch, + } = params; + let start = match cursor { + Some(cursor) => match cursor.parse::() { + Ok(idx) => idx, + Err(_) => return Err(invalid_request(format!("invalid cursor: {cursor}"))), + }, + None => 0, + }; + + let (mut accessible_connectors, mut all_connectors) = tokio::join!( + connectors::list_cached_accessible_connectors_from_mcp_tools(&config), + connectors::list_cached_all_connectors(&config) + ); + let cached_all_connectors = all_connectors.clone(); + + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + + let accessible_config = config.clone(); + let accessible_tx = tx.clone(); + tokio::spawn(async move { + let result = + connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( + &accessible_config, + force_refetch, + &environment_manager, + ) + .await + .map(|status| status.connectors) + .map_err(|err| format!("failed to load accessible apps: {err}")); + let _ = accessible_tx.send(AppListLoadResult::Accessible(result)); + }); + + let all_config = config.clone(); + tokio::spawn(async move { + let result = connectors::list_all_connectors_with_options(&all_config, force_refetch) + .await + .map_err(|err| format!("failed to list apps: {err}")); + let _ = tx.send(AppListLoadResult::Directory(result)); + }); + + let app_list_deadline = tokio::time::Instant::now() + APP_LIST_LOAD_TIMEOUT; + let mut accessible_loaded = false; + let mut all_loaded = false; + let mut last_notified_apps = None; + + if accessible_connectors.is_some() || all_connectors.is_some() { + let merged = connectors::with_app_enabled_state( + merge_loaded_apps(all_connectors.as_deref(), accessible_connectors.as_deref()), + &config, + ); + if should_send_app_list_updated_notification( + merged.as_slice(), + accessible_loaded, + all_loaded, + ) { + send_app_list_updated_notification(outgoing, merged.clone()).await; + last_notified_apps = Some(merged); + } + } + + loop { + let result = match tokio::time::timeout_at(app_list_deadline, rx.recv()).await { + Ok(Some(result)) => result, + Ok(None) => { + return Err(internal_error("failed to load app lists")); + } + Err(_) => { + let timeout_seconds = APP_LIST_LOAD_TIMEOUT.as_secs(); + return Err(internal_error(format!( + "timed out waiting for app lists after {timeout_seconds} seconds" + ))); + } + }; + + match result { + AppListLoadResult::Accessible(Ok(connectors)) => { + accessible_connectors = Some(connectors); + accessible_loaded = true; + } + AppListLoadResult::Accessible(Err(err)) => { + return Err(internal_error(err)); + } + AppListLoadResult::Directory(Ok(connectors)) => { + all_connectors = Some(connectors); + all_loaded = true; + } + AppListLoadResult::Directory(Err(err)) => { + return Err(internal_error(err)); + } + } + + let showing_interim_force_refetch = force_refetch && !(accessible_loaded && all_loaded); + let all_connectors_for_update = + if showing_interim_force_refetch && cached_all_connectors.is_some() { + cached_all_connectors.as_deref() + } else { + all_connectors.as_deref() + }; + let accessible_connectors_for_update = + if showing_interim_force_refetch && !accessible_loaded { + None + } else { + accessible_connectors.as_deref() + }; + let merged = connectors::with_app_enabled_state( + merge_loaded_apps(all_connectors_for_update, accessible_connectors_for_update), + &config, + ); + if should_send_app_list_updated_notification( + merged.as_slice(), + accessible_loaded, + all_loaded, + ) && last_notified_apps.as_ref() != Some(&merged) + { + send_app_list_updated_notification(outgoing, merged.clone()).await; + last_notified_apps = Some(merged.clone()); + } + + if accessible_loaded && all_loaded { + return paginate_apps(merged.as_slice(), start, limit); + } + } + } + + async fn load_thread( + &self, + thread_id: &str, + ) -> Result<(ThreadId, Arc), JSONRPCErrorError> { + let thread_id = ThreadId::from_string(thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + + let thread = self + .thread_manager + .get_thread(thread_id) + .await + .map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?; + + Ok((thread_id, thread)) + } + + async fn load_latest_config( + &self, + fallback_cwd: Option, + ) -> Result { + self.config_manager + .load_latest_config(fallback_cwd) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}"))) + } + + async fn workspace_codex_plugins_enabled( + &self, + config: &Config, + auth: Option<&CodexAuth>, + ) -> bool { + match workspace_settings::codex_plugins_enabled_for_workspace( + config, + auth, + Some(&self.workspace_settings_cache), + ) + .await + { + Ok(enabled) => enabled, + Err(err) => { + warn!( + "failed to fetch workspace Codex plugins setting; allowing Codex plugins: {err:#}" + ); + true + } + } + } +} + +const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90); + +enum AppListLoadResult { + Accessible(Result, String>), + Directory(Result, String>), +} + +fn merge_loaded_apps( + all_connectors: Option<&[AppInfo]>, + accessible_connectors: Option<&[AppInfo]>, +) -> Vec { + let all_connectors_loaded = all_connectors.is_some(); + let all = all_connectors.map_or_else(Vec::new, <[AppInfo]>::to_vec); + let accessible = accessible_connectors.map_or_else(Vec::new, <[AppInfo]>::to_vec); + connectors::merge_connectors_with_accessible(all, accessible, all_connectors_loaded) +} + +fn should_send_app_list_updated_notification( + connectors: &[AppInfo], + accessible_loaded: bool, + all_loaded: bool, +) -> bool { + connectors.iter().any(|connector| connector.is_accessible) || (accessible_loaded && all_loaded) +} + +fn paginate_apps( + connectors: &[AppInfo], + start: usize, + limit: Option, +) -> Result { + let total = connectors.len(); + if start > total { + return Err(invalid_request(format!( + "cursor {start} exceeds total apps {total}" + ))); + } + + let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; + let end = start.saturating_add(effective_limit).min(total); + let data = connectors[start..end].to_vec(); + let next_cursor = if end < total { + Some(end.to_string()) + } else { + None + }; + + Ok(AppsListResponse { data, next_cursor }) +} + +async fn send_app_list_updated_notification( + outgoing: &Arc, + data: Vec, +) { + outgoing + .send_server_notification(ServerNotification::AppListUpdated( + AppListUpdatedNotification { data }, + )) + .await; +} diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs new file mode 100644 index 0000000000..89082492c1 --- /dev/null +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -0,0 +1,580 @@ +use super::*; +use futures::StreamExt; + +#[derive(Clone)] +pub(crate) struct CatalogRequestProcessor { + pub(super) auth_manager: Arc, + pub(super) thread_manager: Arc, + pub(super) config: Arc, + pub(super) config_manager: ConfigManager, + pub(super) workspace_settings_cache: Arc, +} + +const SKILLS_LIST_CWD_CONCURRENCY: usize = 5; + +fn skills_to_info( + skills: &[codex_core::skills::SkillMetadata], + disabled_paths: &HashSet, +) -> Vec { + skills + .iter() + .map(|skill| { + let enabled = !disabled_paths.contains(&skill.path_to_skills_md); + codex_app_server_protocol::SkillMetadata { + name: skill.name.clone(), + description: skill.description.clone(), + short_description: skill.short_description.clone(), + interface: skill.interface.clone().map(|interface| { + codex_app_server_protocol::SkillInterface { + display_name: interface.display_name, + short_description: interface.short_description, + icon_small: interface.icon_small, + icon_large: interface.icon_large, + brand_color: interface.brand_color, + default_prompt: interface.default_prompt, + } + }), + dependencies: skill.dependencies.clone().map(|dependencies| { + codex_app_server_protocol::SkillDependencies { + tools: dependencies + .tools + .into_iter() + .map(|tool| codex_app_server_protocol::SkillToolDependency { + r#type: tool.r#type, + value: tool.value, + description: tool.description, + transport: tool.transport, + command: tool.command, + url: tool.url, + }) + .collect(), + } + }), + path: skill.path_to_skills_md.clone(), + scope: skill.scope.into(), + enabled, + } + }) + .collect() +} + +fn hooks_to_info(hooks: &[codex_hooks::HookListEntry]) -> Vec { + hooks + .iter() + .map(|hook| HookMetadata { + key: hook.key.clone(), + event_name: hook.event_name.into(), + handler_type: hook.handler_type.into(), + matcher: hook.matcher.clone(), + command: hook.command.clone(), + timeout_sec: hook.timeout_sec, + status_message: hook.status_message.clone(), + source_path: hook.source_path.clone(), + source: hook.source.into(), + plugin_id: hook.plugin_id.clone(), + display_order: hook.display_order, + enabled: hook.enabled, + is_managed: hook.is_managed, + current_hash: hook.current_hash.clone(), + trust_status: hook.trust_status.into(), + }) + .collect() +} + +fn errors_to_info( + errors: &[codex_core::skills::SkillError], +) -> Vec { + errors + .iter() + .map(|err| codex_app_server_protocol::SkillErrorInfo { + path: err.path.to_path_buf(), + message: err.message.clone(), + }) + .collect() +} + +impl CatalogRequestProcessor { + pub(crate) fn new( + auth_manager: Arc, + thread_manager: Arc, + config: Arc, + config_manager: ConfigManager, + workspace_settings_cache: Arc, + ) -> Self { + Self { + auth_manager, + thread_manager, + config, + config_manager, + workspace_settings_cache, + } + } + + pub(crate) async fn skills_list( + &self, + params: SkillsListParams, + ) -> Result, JSONRPCErrorError> { + self.skills_list_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn hooks_list( + &self, + params: HooksListParams, + ) -> Result, JSONRPCErrorError> { + self.hooks_list_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn skills_config_write( + &self, + params: SkillsConfigWriteParams, + ) -> Result, JSONRPCErrorError> { + self.skills_config_write_response_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn model_list( + &self, + params: ModelListParams, + ) -> Result, JSONRPCErrorError> { + Self::list_models(self.thread_manager.clone(), params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn experimental_feature_list( + &self, + params: ExperimentalFeatureListParams, + ) -> Result, JSONRPCErrorError> { + self.experimental_feature_list_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn collaboration_mode_list( + &self, + params: CollaborationModeListParams, + ) -> Result, JSONRPCErrorError> { + Self::list_collaboration_modes(self.thread_manager.clone(), params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn mock_experimental_method( + &self, + params: MockExperimentalMethodParams, + ) -> Result, JSONRPCErrorError> { + self.mock_experimental_method_inner(params) + .await + .map(|response| Some(response.into())) + } + + async fn resolve_cwd_config( + &self, + cwd: &Path, + ) -> Result<(AbsolutePathBuf, ConfigLayerStack), String> { + let cwd_abs = + AbsolutePathBuf::relative_to_current_dir(cwd).map_err(|err| err.to_string())?; + let config_layer_stack = self + .config_manager + .load_config_layers_for_cwd(cwd_abs.clone()) + .await + .map_err(|err| err.to_string())?; + + Ok((cwd_abs, config_layer_stack)) + } + + async fn load_latest_config( + &self, + fallback_cwd: Option, + ) -> Result { + self.config_manager + .load_latest_config(fallback_cwd) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}"))) + } + + async fn workspace_codex_plugins_enabled( + &self, + config: &Config, + auth: Option<&CodexAuth>, + ) -> bool { + match workspace_settings::codex_plugins_enabled_for_workspace( + config, + auth, + Some(&self.workspace_settings_cache), + ) + .await + { + Ok(enabled) => enabled, + Err(err) => { + warn!( + "failed to fetch workspace Codex plugins setting; allowing Codex plugins: {err:#}" + ); + true + } + } + } + + async fn list_models( + thread_manager: Arc, + params: ModelListParams, + ) -> Result { + let ModelListParams { + limit, + cursor, + include_hidden, + } = params; + let models = supported_models(thread_manager, include_hidden.unwrap_or(false)).await; + let total = models.len(); + + if total == 0 { + return Ok(ModelListResponse { + data: Vec::new(), + next_cursor: None, + }); + } + + let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; + let effective_limit = effective_limit.min(total); + let start = match cursor { + Some(cursor) => cursor + .parse::() + .map_err(|_| invalid_request(format!("invalid cursor: {cursor}")))?, + None => 0, + }; + + if start > total { + return Err(invalid_request(format!( + "cursor {start} exceeds total models {total}" + ))); + } + + let end = start.saturating_add(effective_limit).min(total); + let items = models[start..end].to_vec(); + let next_cursor = if end < total { + Some(end.to_string()) + } else { + None + }; + Ok(ModelListResponse { + data: items, + next_cursor, + }) + } + + async fn list_collaboration_modes( + thread_manager: Arc, + params: CollaborationModeListParams, + ) -> Result { + let CollaborationModeListParams {} = params; + let items = thread_manager + .list_collaboration_modes() + .into_iter() + .map(Into::into) + .collect(); + let response = CollaborationModeListResponse { data: items }; + Ok(response) + } + + async fn experimental_feature_list_response( + &self, + params: ExperimentalFeatureListParams, + ) -> Result { + let ExperimentalFeatureListParams { cursor, limit } = params; + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let auth = self.auth_manager.auth().await; + let workspace_codex_plugins_enabled = self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await; + + let data = FEATURES + .iter() + .map(|spec| { + let (stage, display_name, description, announcement) = match spec.stage { + Stage::Experimental { + name, + menu_description, + announcement, + } => ( + ApiExperimentalFeatureStage::Beta, + Some(name.to_string()), + Some(menu_description.to_string()), + Some(announcement.to_string()), + ), + Stage::UnderDevelopment => ( + ApiExperimentalFeatureStage::UnderDevelopment, + None, + None, + None, + ), + Stage::Stable => (ApiExperimentalFeatureStage::Stable, None, None, None), + Stage::Deprecated => { + (ApiExperimentalFeatureStage::Deprecated, None, None, None) + } + Stage::Removed => (ApiExperimentalFeatureStage::Removed, None, None, None), + }; + + ApiExperimentalFeature { + name: spec.key.to_string(), + stage, + display_name, + description, + announcement, + enabled: config.features.enabled(spec.id) + && (workspace_codex_plugins_enabled + || !matches!(spec.id, Feature::Apps | Feature::Plugins)), + default_enabled: spec.default_enabled, + } + }) + .collect::>(); + + let total = data.len(); + if total == 0 { + return Ok(ExperimentalFeatureListResponse { + data: Vec::new(), + next_cursor: None, + }); + } + + // Clamp to 1 so limit=0 cannot return a non-advancing page. + let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; + let effective_limit = effective_limit.min(total); + let start = match cursor { + Some(cursor) => match cursor.parse::() { + Ok(idx) => idx, + Err(_) => return Err(invalid_request(format!("invalid cursor: {cursor}"))), + }, + None => 0, + }; + + if start > total { + return Err(invalid_request(format!( + "cursor {start} exceeds total feature flags {total}" + ))); + } + + let end = start.saturating_add(effective_limit).min(total); + let data = data[start..end].to_vec(); + let next_cursor = if end < total { + Some(end.to_string()) + } else { + None + }; + + Ok(ExperimentalFeatureListResponse { data, next_cursor }) + } + + async fn mock_experimental_method_inner( + &self, + params: MockExperimentalMethodParams, + ) -> Result { + let MockExperimentalMethodParams { value } = params; + let response = MockExperimentalMethodResponse { echoed: value }; + Ok(response) + } + + async fn skills_list_response( + &self, + params: SkillsListParams, + ) -> Result { + let SkillsListParams { cwds, force_reload } = params; + let cwds = if cwds.is_empty() { + vec![self.config.cwd.to_path_buf()] + } else { + cwds + }; + + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let auth = self.auth_manager.auth().await; + let workspace_codex_plugins_enabled = self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await; + let skills_manager = self.thread_manager.skills_manager(); + let plugins_manager = self.thread_manager.plugins_manager(); + let fs = self + .thread_manager + .environment_manager() + .default_environment() + .map(|environment| environment.get_filesystem()); + let mut data = futures::stream::iter(cwds.into_iter().enumerate()) + .map(|(index, cwd)| { + let config = &config; + let fs = fs.clone(); + let plugins_manager = &plugins_manager; + let skills_manager = &skills_manager; + async move { + let (cwd_abs, config_layer_stack) = match self.resolve_cwd_config(&cwd).await { + Ok(resolved) => resolved, + Err(message) => { + let error_path = cwd.clone(); + return ( + index, + codex_app_server_protocol::SkillsListEntry { + cwd, + skills: Vec::new(), + errors: vec![codex_app_server_protocol::SkillErrorInfo { + path: error_path, + message, + }], + }, + ); + } + }; + let effective_skill_roots = if workspace_codex_plugins_enabled { + let plugins_input = config.plugins_config_input(); + plugins_manager + .effective_skill_roots_for_layer_stack( + &config_layer_stack, + &plugins_input, + ) + .await + } else { + Vec::new() + }; + let skills_input = codex_core::skills::SkillsLoadInput::new( + cwd_abs.clone(), + effective_skill_roots, + config_layer_stack, + config.bundled_skills_enabled(), + ); + let outcome = skills_manager + .skills_for_cwd(&skills_input, force_reload, fs) + .await; + let errors = errors_to_info(&outcome.errors); + let skills = skills_to_info(&outcome.skills, &outcome.disabled_paths); + ( + index, + codex_app_server_protocol::SkillsListEntry { + cwd, + skills, + errors, + }, + ) + } + }) + .buffer_unordered(SKILLS_LIST_CWD_CONCURRENCY) + .collect::>() + .await; + data.sort_unstable_by_key(|(index, _)| *index); + let data = data.into_iter().map(|(_, entry)| entry).collect(); + Ok(SkillsListResponse { data }) + } + + /// Handle `hooks/list` by resolving hooks for each requested cwd. + async fn hooks_list_response( + &self, + params: HooksListParams, + ) -> Result { + let HooksListParams { cwds } = params; + let cwds = if cwds.is_empty() { + vec![self.config.cwd.to_path_buf()] + } else { + cwds + }; + + let auth = self.auth_manager.auth().await; + let plugins_manager = self.thread_manager.plugins_manager(); + let mut data = Vec::new(); + for cwd in cwds { + let config = match self + .config_manager + .load_for_cwd( + /*request_overrides*/ None, + ConfigOverrides::default(), + Some(cwd.clone()), + ) + .await + { + Ok(config) => config, + Err(err) => { + let error_path = cwd.clone(); + data.push(codex_app_server_protocol::HooksListEntry { + cwd, + hooks: Vec::new(), + warnings: Vec::new(), + errors: vec![codex_app_server_protocol::HookErrorInfo { + path: error_path, + message: err.to_string(), + }], + }); + continue; + } + }; + let workspace_codex_plugins_enabled = self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await; + let plugins_enabled = + config.features.enabled(Feature::Plugins) && workspace_codex_plugins_enabled; + let plugin_outcome = if plugins_enabled && config.features.enabled(Feature::PluginHooks) + { + let plugins_input = config.plugins_config_input(); + plugins_manager + .plugins_for_layer_stack( + &config.config_layer_stack, + &plugins_input, + /*plugin_hooks_feature_enabled*/ true, + ) + .await + } else { + PluginLoadOutcome::default() + }; + let hooks = codex_hooks::list_hooks(codex_hooks::HooksConfig { + feature_enabled: config.features.enabled(Feature::CodexHooks), + config_layer_stack: Some(config.config_layer_stack), + plugin_hook_sources: plugin_outcome.effective_plugin_hook_sources(), + plugin_hook_load_warnings: plugin_outcome.effective_plugin_hook_warnings(), + ..Default::default() + }); + data.push(codex_app_server_protocol::HooksListEntry { + cwd, + hooks: hooks_to_info(&hooks.hooks), + warnings: hooks.warnings, + errors: Vec::new(), + }); + } + Ok(HooksListResponse { data }) + } + + async fn skills_config_write_response_inner( + &self, + params: SkillsConfigWriteParams, + ) -> Result { + let SkillsConfigWriteParams { + path, + name, + enabled, + } = params; + let edit = match (path, name) { + (Some(path), None) => ConfigEdit::SetSkillConfig { + path: path.into_path_buf(), + enabled, + }, + (None, Some(name)) if !name.trim().is_empty() => { + ConfigEdit::SetSkillConfigByName { name, enabled } + } + _ => { + return Err(invalid_params( + "skills/config/write requires exactly one of path or name", + )); + } + }; + let edits = vec![edit]; + ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits(edits) + .apply() + .await + .map(|()| { + self.thread_manager.plugins_manager().clear_cache(); + self.thread_manager.skills_manager().clear_cache(); + SkillsConfigWriteResponse { + effective_enabled: enabled, + } + }) + .map_err(|err| internal_error(format!("failed to update skill settings: {err}"))) + } +} diff --git a/codex-rs/app-server/src/request_processors/command_exec_processor.rs b/codex-rs/app-server/src/request_processors/command_exec_processor.rs new file mode 100644 index 0000000000..3236a67627 --- /dev/null +++ b/codex-rs/app-server/src/request_processors/command_exec_processor.rs @@ -0,0 +1,321 @@ +use super::*; + +#[derive(Clone)] +pub(crate) struct CommandExecRequestProcessor { + arg0_paths: Arg0DispatchPaths, + config: Arc, + outgoing: Arc, + command_exec_manager: CommandExecManager, +} + +impl CommandExecRequestProcessor { + pub(crate) fn new( + arg0_paths: Arg0DispatchPaths, + config: Arc, + outgoing: Arc, + ) -> Self { + Self { + arg0_paths, + config, + outgoing, + command_exec_manager: CommandExecManager::default(), + } + } + + pub(crate) async fn one_off_command_exec( + &self, + request_id: &ConnectionRequestId, + params: CommandExecParams, + ) -> Result, JSONRPCErrorError> { + self.exec_one_off_command(request_id, params) + .await + .map(|()| None) + } + + pub(crate) async fn command_exec_write( + &self, + request_id: ConnectionRequestId, + params: CommandExecWriteParams, + ) -> Result, JSONRPCErrorError> { + self.command_exec_manager + .write(request_id, params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn command_exec_resize( + &self, + request_id: ConnectionRequestId, + params: CommandExecResizeParams, + ) -> Result, JSONRPCErrorError> { + self.command_exec_manager + .resize(request_id, params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn command_exec_terminate( + &self, + request_id: ConnectionRequestId, + params: CommandExecTerminateParams, + ) -> Result, JSONRPCErrorError> { + self.command_exec_manager + .terminate(request_id, params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) { + self.command_exec_manager + .connection_closed(connection_id) + .await; + } + + async fn exec_one_off_command( + &self, + request_id: &ConnectionRequestId, + params: CommandExecParams, + ) -> Result<(), JSONRPCErrorError> { + self.exec_one_off_command_inner(request_id.clone(), params) + .await + } + + async fn exec_one_off_command_inner( + &self, + request_id: ConnectionRequestId, + params: CommandExecParams, + ) -> Result<(), JSONRPCErrorError> { + tracing::debug!("ExecOneOffCommand params: {params:?}"); + + let request = request_id.clone(); + + if params.command.is_empty() { + return Err(invalid_request("command must not be empty")); + } + + let CommandExecParams { + command, + process_id, + tty, + stream_stdin, + stream_stdout_stderr, + output_bytes_cap, + disable_output_cap, + disable_timeout, + timeout_ms, + cwd, + env: env_overrides, + size, + sandbox_policy, + permission_profile, + } = params; + if sandbox_policy.is_some() && permission_profile.is_some() { + return Err(invalid_request( + "`permissionProfile` cannot be combined with `sandboxPolicy`", + )); + } + + if size.is_some() && !tty { + return Err(invalid_params("command/exec size requires tty: true")); + } + + if disable_output_cap && output_bytes_cap.is_some() { + return Err(invalid_params( + "command/exec cannot set both outputBytesCap and disableOutputCap", + )); + } + + if disable_timeout && timeout_ms.is_some() { + return Err(invalid_params( + "command/exec cannot set both timeoutMs and disableTimeout", + )); + } + + let cwd = cwd.map_or_else(|| self.config.cwd.clone(), |cwd| self.config.cwd.join(cwd)); + let mut env = create_env( + &self.config.permissions.shell_environment_policy, + /*thread_id*/ None, + ); + if let Some(env_overrides) = env_overrides { + for (key, value) in env_overrides { + match value { + Some(value) => { + env.insert(key, value); + } + None => { + env.remove(&key); + } + } + } + } + let timeout_ms = match timeout_ms { + Some(timeout_ms) => match u64::try_from(timeout_ms) { + Ok(timeout_ms) => Some(timeout_ms), + Err(_) => { + return Err(invalid_params(format!( + "command/exec timeoutMs must be non-negative, got {timeout_ms}" + ))); + } + }, + None => None, + }; + let managed_network_requirements_enabled = + self.config.managed_network_requirements_enabled(); + let started_network_proxy = match self.config.permissions.network.as_ref() { + Some(spec) => match spec + .start_proxy( + self.config.permissions.permission_profile.get(), + /*policy_decider*/ None, + /*blocked_request_observer*/ None, + managed_network_requirements_enabled, + NetworkProxyAuditMetadata::default(), + ) + .await + { + Ok(started) => Some(started), + Err(err) => { + return Err(internal_error(format!( + "failed to start managed network proxy: {err}" + ))); + } + }, + None => None, + }; + let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); + let output_bytes_cap = if disable_output_cap { + None + } else { + Some(output_bytes_cap.unwrap_or(DEFAULT_OUTPUT_BYTES_CAP)) + }; + let expiration = if disable_timeout { + ExecExpiration::Cancellation(CancellationToken::new()) + } else { + match timeout_ms { + Some(timeout_ms) => timeout_ms.into(), + None => ExecExpiration::DefaultTimeout, + } + }; + let capture_policy = if disable_output_cap { + ExecCapturePolicy::FullBuffer + } else { + ExecCapturePolicy::ShellTool + }; + let sandbox_cwd = if permission_profile.is_some() { + cwd.clone() + } else { + self.config.cwd.clone() + }; + let exec_params = ExecParams { + command, + cwd: cwd.clone(), + expiration, + capture_policy, + env, + network: started_network_proxy + .as_ref() + .map(codex_core::config::StartedNetworkProxy::proxy), + sandbox_permissions: SandboxPermissions::UseDefault, + windows_sandbox_level, + windows_sandbox_private_desktop: self + .config + .permissions + .windows_sandbox_private_desktop, + justification: None, + arg0: None, + }; + + let effective_permission_profile = if let Some(permission_profile) = permission_profile { + let permission_profile = + codex_protocol::models::PermissionProfile::from(permission_profile); + let (mut file_system_sandbox_policy, network_sandbox_policy) = + permission_profile.to_runtime_permissions(); + let configured_file_system_sandbox_policy = + self.config.permissions.file_system_sandbox_policy(); + Self::preserve_configured_deny_read_restrictions( + &mut file_system_sandbox_policy, + &configured_file_system_sandbox_policy, + ); + let effective_permission_profile = + codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement( + permission_profile.enforcement(), + &file_system_sandbox_policy, + network_sandbox_policy, + ); + self.config + .permissions + .permission_profile + .can_set(&effective_permission_profile) + .map_err(|err| invalid_request(format!("invalid permission profile: {err}")))?; + effective_permission_profile + } else if let Some(policy) = sandbox_policy.map(|policy| policy.to_core()) { + self.config + .permissions + .can_set_legacy_sandbox_policy(&policy, &sandbox_cwd) + .map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?; + let file_system_sandbox_policy = + codex_protocol::permissions::FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, &sandbox_cwd); + let network_sandbox_policy = + codex_protocol::permissions::NetworkSandboxPolicy::from(&policy); + let permission_profile = + codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement( + codex_protocol::models::SandboxEnforcement::from_legacy_sandbox_policy(&policy), + &file_system_sandbox_policy, + network_sandbox_policy, + ); + self.config + .permissions + .permission_profile + .can_set(&permission_profile) + .map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?; + permission_profile + } else { + self.config.permissions.permission_profile() + }; + + let codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); + let outgoing = self.outgoing.clone(); + let request_for_task = request.clone(); + let started_network_proxy_for_task = started_network_proxy; + let use_legacy_landlock = self.config.features.use_legacy_landlock(); + let size = match size.map(crate::command_exec::terminal_size_from_protocol) { + Some(Ok(size)) => Some(size), + Some(Err(error)) => return Err(error), + None => None, + }; + + let exec_request = codex_core::exec::build_exec_request( + exec_params, + &effective_permission_profile, + &sandbox_cwd, + &codex_linux_sandbox_exe, + use_legacy_landlock, + ) + .map_err(|err| internal_error(format!("exec failed: {err}")))?; + self.command_exec_manager + .start(StartCommandExecParams { + outgoing, + request_id: request_for_task, + process_id, + exec_request, + started_network_proxy: started_network_proxy_for_task, + tty, + stream_stdin, + stream_stdout_stderr, + output_bytes_cap, + size, + }) + .await + } + + fn preserve_configured_deny_read_restrictions( + file_system_sandbox_policy: &mut FileSystemSandboxPolicy, + configured_file_system_sandbox_policy: &FileSystemSandboxPolicy, + ) { + file_system_sandbox_policy + .preserve_deny_read_restrictions_from(configured_file_system_sandbox_policy); + } +} + +#[cfg(test)] +#[path = "command_exec_processor_tests.rs"] +mod command_exec_processor_tests; diff --git a/codex-rs/app-server/src/request_processors/command_exec_processor_tests.rs b/codex-rs/app-server/src/request_processors/command_exec_processor_tests.rs new file mode 100644 index 0000000000..3e026a6a82 --- /dev/null +++ b/codex-rs/app-server/src/request_processors/command_exec_processor_tests.rs @@ -0,0 +1,38 @@ +use super::*; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_utils_absolute_path::test_support::PathBufExt; +use codex_utils_absolute_path::test_support::test_path_buf; +use pretty_assertions::assert_eq; + +#[test] +fn command_profile_preserves_configured_deny_read_restrictions() { + let readable_entry = FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: test_path_buf("/tmp/project").abs(), + }, + access: FileSystemAccessMode::Read, + }; + let deny_entry = FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "/tmp/project/**/*.env".to_string(), + }, + access: FileSystemAccessMode::None, + }; + let mut file_system_sandbox_policy = + FileSystemSandboxPolicy::restricted(vec![readable_entry.clone()]); + let mut configured_file_system_sandbox_policy = + FileSystemSandboxPolicy::restricted(vec![deny_entry.clone()]); + configured_file_system_sandbox_policy.glob_scan_max_depth = Some(2); + + CommandExecRequestProcessor::preserve_configured_deny_read_restrictions( + &mut file_system_sandbox_policy, + &configured_file_system_sandbox_policy, + ); + + let mut expected = FileSystemSandboxPolicy::restricted(vec![readable_entry, deny_entry]); + expected.glob_scan_max_depth = Some(2); + assert_eq!(file_system_sandbox_policy, expected); +} diff --git a/codex-rs/app-server/src/request_processors/config_errors.rs b/codex-rs/app-server/src/request_processors/config_errors.rs new file mode 100644 index 0000000000..63fe2b3d2c --- /dev/null +++ b/codex-rs/app-server/src/request_processors/config_errors.rs @@ -0,0 +1,35 @@ +use super::*; + +fn cloud_requirements_load_error(err: &std::io::Error) -> Option<&CloudRequirementsLoadError> { + let mut current: Option<&(dyn std::error::Error + 'static)> = err + .get_ref() + .map(|source| source as &(dyn std::error::Error + 'static)); + while let Some(source) = current { + if let Some(cloud_error) = source.downcast_ref::() { + return Some(cloud_error); + } + current = source.source(); + } + None +} + +pub(super) fn config_load_error(err: &std::io::Error) -> JSONRPCErrorError { + let data = cloud_requirements_load_error(err).map(|cloud_error| { + let mut data = serde_json::json!({ + "reason": "cloudRequirements", + "errorCode": format!("{:?}", cloud_error.code()), + "detail": cloud_error.to_string(), + }); + if let Some(status_code) = cloud_error.status_code() { + data["statusCode"] = serde_json::json!(status_code); + } + if cloud_error.code() == CloudRequirementsLoadErrorCode::Auth { + data["action"] = serde_json::json!("relogin"); + } + data + }); + + let mut error = invalid_request(format!("failed to load configuration: {err}")); + error.data = data; + error +} diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs new file mode 100644 index 0000000000..b7f973b076 --- /dev/null +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -0,0 +1,632 @@ +use std::sync::Arc; + +use crate::config_manager::ConfigManager; +use crate::config_manager_service::ConfigManagerError; +use crate::error_code::internal_error; +use crate::error_code::invalid_request; +use crate::outgoing_message::ConnectionRequestId; +use crate::outgoing_message::OutgoingMessageSender; +use codex_analytics::AnalyticsEventsClient; +use codex_app_server_protocol::AppListUpdatedNotification; +use codex_app_server_protocol::ClientResponsePayload; +use codex_app_server_protocol::ConfigBatchWriteParams; +use codex_app_server_protocol::ConfigReadParams; +use codex_app_server_protocol::ConfigReadResponse; +use codex_app_server_protocol::ConfigRequirements; +use codex_app_server_protocol::ConfigRequirementsReadResponse; +use codex_app_server_protocol::ConfigValueWriteParams; +use codex_app_server_protocol::ConfigWriteErrorCode; +use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::ConfiguredHookHandler; +use codex_app_server_protocol::ConfiguredHookMatcherGroup; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::ManagedHooksRequirements; +use codex_app_server_protocol::ModelProviderCapabilitiesReadResponse; +use codex_app_server_protocol::NetworkDomainPermission; +use codex_app_server_protocol::NetworkRequirements; +use codex_app_server_protocol::NetworkUnixSocketPermission; +use codex_app_server_protocol::SandboxMode; +use codex_app_server_protocol::ServerNotification; +use codex_chatgpt::connectors; +use codex_config::ConfigRequirementsToml; +use codex_config::HookEventsToml; +use codex_config::HookHandlerConfig as CoreHookHandlerConfig; +use codex_config::ManagedHooksRequirementsToml; +use codex_config::MatcherGroup as CoreMatcherGroup; +use codex_config::ResidencyRequirement as CoreResidencyRequirement; +use codex_config::SandboxModeRequirement as CoreSandboxModeRequirement; +use codex_core::ThreadManager; +use codex_features::canonical_feature_for_key; +use codex_features::feature_for_key; +use codex_login::AuthManager; +use codex_model_provider::create_model_provider; +use codex_plugin::PluginId; +use codex_protocol::config_types::WebSearchMode; +use serde_json::json; +use std::path::PathBuf; + +const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ + "apps", + "memories", + "mentions_v2", + "plugins", + "remote_control", + "tool_search", + "tool_suggest", + "tool_call_mcp_elicitation", +]; + +#[derive(Clone)] +pub(crate) struct ConfigRequestProcessor { + outgoing: Arc, + config_manager: ConfigManager, + auth_manager: Arc, + thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, +} + +impl ConfigRequestProcessor { + pub(crate) fn new( + outgoing: Arc, + config_manager: ConfigManager, + auth_manager: Arc, + thread_manager: Arc, + analytics_events_client: AnalyticsEventsClient, + ) -> Self { + Self { + outgoing, + config_manager, + auth_manager, + thread_manager, + analytics_events_client, + } + } + + pub(crate) async fn read( + &self, + params: ConfigReadParams, + ) -> Result { + let fallback_cwd = params.cwd.as_ref().map(PathBuf::from); + let mut response = self.config_manager.read(params).await.map_err(map_error)?; + let config = self.load_latest_config(fallback_cwd).await?; + for feature_key in SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT { + let Some(feature) = feature_for_key(feature_key) else { + continue; + }; + let features = response + .config + .additional + .entry("features".to_string()) + .or_insert_with(|| json!({})); + if !features.is_object() { + *features = json!({}); + } + if let Some(features) = features.as_object_mut() { + features.insert( + (*feature_key).to_string(), + json!(config.features.enabled(feature)), + ); + } + } + Ok(response) + } + + pub(crate) async fn config_requirements_read( + &self, + ) -> Result { + let requirements = self + .config_manager + .read_requirements() + .await + .map_err(map_error)? + .map(map_requirements_toml_to_api); + + Ok(ConfigRequirementsReadResponse { requirements }) + } + + pub(crate) async fn value_write( + &self, + params: ConfigValueWriteParams, + ) -> Result { + self.handle_config_mutation_result(self.write_value(params).await) + .await + .map(ClientResponsePayload::ConfigValueWrite) + } + + pub(crate) async fn batch_write( + &self, + params: ConfigBatchWriteParams, + ) -> Result { + self.handle_config_mutation_result(self.batch_write_inner(params).await) + .await + .map(ClientResponsePayload::ConfigBatchWrite) + } + + pub(crate) async fn experimental_feature_enablement_set( + &self, + request_id: ConnectionRequestId, + params: ExperimentalFeatureEnablementSetParams, + ) -> Result, JSONRPCErrorError> { + let should_refresh_apps_list = params.enablement.get("apps").copied() == Some(true); + let response = self + .handle_config_mutation_result(self.set_experimental_feature_enablement(params).await) + .await?; + self.outgoing + .send_response_as( + request_id, + ClientResponsePayload::ExperimentalFeatureEnablementSet(response), + ) + .await; + if should_refresh_apps_list { + self.refresh_apps_list_after_experimental_feature_enablement_set() + .await; + } + Ok(None) + } + + pub(crate) async fn model_provider_capabilities_read( + &self, + ) -> Result { + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let provider = create_model_provider(config.model_provider, /*auth_manager*/ None); + let capabilities = provider.capabilities(); + Ok(ModelProviderCapabilitiesReadResponse { + namespace_tools: capabilities.namespace_tools, + image_generation: capabilities.image_generation, + web_search: capabilities.web_search, + }) + } + + pub(crate) async fn handle_config_mutation(&self) { + self.thread_manager.plugins_manager().clear_cache(); + self.thread_manager.skills_manager().clear_cache(); + } + + async fn handle_config_mutation_result( + &self, + result: std::result::Result, + ) -> Result { + let response = result?; + self.handle_config_mutation().await; + Ok(response) + } + + async fn refresh_apps_list_after_experimental_feature_enablement_set(&self) { + let config = match self.load_latest_config(/*fallback_cwd*/ None).await { + Ok(config) => config, + Err(error) => { + tracing::warn!( + "failed to load config for apps list refresh after experimental feature enablement: {}", + error.message + ); + return; + } + }; + let auth = self.auth_manager.auth().await; + if !config.features.apps_enabled_for_auth( + auth.as_ref() + .is_some_and(codex_login::CodexAuth::uses_codex_backend), + ) { + return; + } + + let outgoing = Arc::clone(&self.outgoing); + let environment_manager = self.thread_manager.environment_manager(); + tokio::spawn(async move { + let (all_connectors_result, accessible_connectors_result) = tokio::join!( + connectors::list_all_connectors_with_options(&config, /*force_refetch*/ true), + connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( + &config, + /*force_refetch*/ true, + &environment_manager, + ), + ); + let all_connectors = match all_connectors_result { + Ok(connectors) => connectors, + Err(err) => { + tracing::warn!( + "failed to force-refresh directory apps after experimental feature enablement: {err:#}" + ); + return; + } + }; + let accessible_connectors = match accessible_connectors_result { + Ok(status) => status.connectors, + Err(err) => { + tracing::warn!( + "failed to force-refresh accessible apps after experimental feature enablement: {err:#}" + ); + return; + } + }; + + let data = connectors::with_app_enabled_state( + connectors::merge_connectors_with_accessible( + all_connectors, + accessible_connectors, + /*all_connectors_loaded*/ true, + ), + &config, + ); + outgoing + .send_server_notification(ServerNotification::AppListUpdated( + AppListUpdatedNotification { data }, + )) + .await; + }); + } + + async fn load_latest_config( + &self, + fallback_cwd: Option, + ) -> Result { + self.config_manager + .load_latest_config(fallback_cwd) + .await + .map_err(|err| { + internal_error(format!( + "failed to resolve feature override precedence: {err}" + )) + }) + } + + async fn write_value( + &self, + params: ConfigValueWriteParams, + ) -> Result { + let pending_changes = codex_core_plugins::toggles::collect_plugin_enabled_candidates( + [(¶ms.key_path, ¶ms.value)].into_iter(), + ); + let response = self + .config_manager + .write_value(params) + .await + .map_err(map_error)?; + self.emit_plugin_toggle_events(pending_changes).await; + Ok(response) + } + + async fn batch_write_inner( + &self, + params: ConfigBatchWriteParams, + ) -> Result { + let reload_user_config = params.reload_user_config; + let pending_changes = codex_core_plugins::toggles::collect_plugin_enabled_candidates( + params + .edits + .iter() + .map(|edit| (&edit.key_path, &edit.value)), + ); + let response = self + .config_manager + .batch_write(params) + .await + .map_err(map_error)?; + self.emit_plugin_toggle_events(pending_changes).await; + if reload_user_config { + self.reload_user_config().await; + } + Ok(response) + } + + async fn set_experimental_feature_enablement( + &self, + params: ExperimentalFeatureEnablementSetParams, + ) -> Result { + let ExperimentalFeatureEnablementSetParams { enablement } = params; + for key in enablement.keys() { + if canonical_feature_for_key(key).is_some() { + if SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()) { + continue; + } + + return Err(invalid_request(format!( + "unsupported feature enablement `{key}`: currently supported features are {}", + SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ") + ))); + } + + let message = if let Some(feature) = feature_for_key(key) { + format!( + "invalid feature enablement `{key}`: use canonical feature key `{}`", + feature.key() + ) + } else { + format!("invalid feature enablement `{key}`") + }; + return Err(invalid_request(message)); + } + + if enablement.is_empty() { + return Ok(ExperimentalFeatureEnablementSetResponse { enablement }); + } + + self.config_manager + .extend_runtime_feature_enablement( + enablement + .iter() + .map(|(name, enabled)| (name.clone(), *enabled)), + ) + .map_err(|_| internal_error("failed to update feature enablement"))?; + + self.load_latest_config(/*fallback_cwd*/ None).await?; + self.reload_user_config().await; + + Ok(ExperimentalFeatureEnablementSetResponse { enablement }) + } + + async fn reload_user_config(&self) { + let next_config = match self.load_latest_config(/*fallback_cwd*/ None).await { + Ok(config) => config, + Err(err) => { + tracing::warn!( + "failed to rebuild user config for runtime refresh: {}", + err.message + ); + return; + } + }; + let thread_ids = self.thread_manager.list_thread_ids().await; + for thread_id in thread_ids { + let Ok(thread) = self.thread_manager.get_thread(thread_id).await else { + continue; + }; + thread.refresh_runtime_config(next_config.clone()).await; + } + } + + async fn emit_plugin_toggle_events( + &self, + pending_changes: std::collections::BTreeMap, + ) { + for (plugin_id, enabled) in pending_changes { + let Ok(plugin_id) = PluginId::parse(&plugin_id) else { + continue; + }; + let metadata = codex_core_plugins::loader::installed_plugin_telemetry_metadata( + self.config_manager.codex_home(), + &plugin_id, + ) + .await; + if enabled { + self.analytics_events_client.track_plugin_enabled(metadata); + } else { + self.analytics_events_client.track_plugin_disabled(metadata); + } + } + } +} + +fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements { + ConfigRequirements { + allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| { + policies + .into_iter() + .map(codex_app_server_protocol::AskForApproval::from) + .collect() + }), + allowed_approvals_reviewers: requirements.allowed_approvals_reviewers.map(|reviewers| { + reviewers + .into_iter() + .map(codex_app_server_protocol::ApprovalsReviewer::from) + .collect() + }), + allowed_sandbox_modes: requirements.allowed_sandbox_modes.map(|modes| { + modes + .into_iter() + .filter_map(map_sandbox_mode_requirement_to_api) + .collect() + }), + allowed_web_search_modes: requirements.allowed_web_search_modes.map(|modes| { + let mut normalized = modes + .into_iter() + .map(Into::into) + .collect::>(); + if !normalized.contains(&WebSearchMode::Disabled) { + normalized.push(WebSearchMode::Disabled); + } + normalized + }), + allow_managed_hooks_only: requirements.allow_managed_hooks_only, + feature_requirements: requirements + .feature_requirements + .map(|requirements| requirements.entries), + hooks: requirements.hooks.map(map_hooks_requirements_to_api), + enforce_residency: requirements + .enforce_residency + .map(map_residency_requirement_to_api), + network: requirements.network.map(map_network_requirements_to_api), + } +} + +fn map_hooks_requirements_to_api(hooks: ManagedHooksRequirementsToml) -> ManagedHooksRequirements { + let ManagedHooksRequirementsToml { + managed_dir, + windows_managed_dir, + hooks, + } = hooks; + let HookEventsToml { + pre_tool_use, + permission_request, + post_tool_use, + pre_compact, + post_compact, + session_start, + user_prompt_submit, + stop, + } = hooks; + + ManagedHooksRequirements { + managed_dir, + windows_managed_dir, + pre_tool_use: map_hook_matcher_groups_to_api(pre_tool_use), + permission_request: map_hook_matcher_groups_to_api(permission_request), + post_tool_use: map_hook_matcher_groups_to_api(post_tool_use), + pre_compact: map_hook_matcher_groups_to_api(pre_compact), + post_compact: map_hook_matcher_groups_to_api(post_compact), + session_start: map_hook_matcher_groups_to_api(session_start), + user_prompt_submit: map_hook_matcher_groups_to_api(user_prompt_submit), + stop: map_hook_matcher_groups_to_api(stop), + } +} + +fn map_hook_matcher_groups_to_api( + groups: Vec, +) -> Vec { + groups + .into_iter() + .map(map_hook_matcher_group_to_api) + .collect() +} + +fn map_hook_matcher_group_to_api(group: CoreMatcherGroup) -> ConfiguredHookMatcherGroup { + ConfiguredHookMatcherGroup { + matcher: group.matcher, + hooks: group + .hooks + .into_iter() + .map(map_hook_handler_to_api) + .collect(), + } +} + +fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHandler { + match handler { + CoreHookHandlerConfig::Command { + command, + command_windows, + timeout_sec, + r#async, + status_message, + } => ConfiguredHookHandler::Command { + command, + command_windows, + timeout_sec, + r#async, + status_message, + }, + CoreHookHandlerConfig::Prompt {} => ConfiguredHookHandler::Prompt {}, + CoreHookHandlerConfig::Agent {} => ConfiguredHookHandler::Agent {}, + } +} + +fn map_sandbox_mode_requirement_to_api(mode: CoreSandboxModeRequirement) -> Option { + match mode { + CoreSandboxModeRequirement::ReadOnly => Some(SandboxMode::ReadOnly), + CoreSandboxModeRequirement::WorkspaceWrite => Some(SandboxMode::WorkspaceWrite), + CoreSandboxModeRequirement::DangerFullAccess => Some(SandboxMode::DangerFullAccess), + CoreSandboxModeRequirement::ExternalSandbox => None, + } +} + +fn map_residency_requirement_to_api( + residency: CoreResidencyRequirement, +) -> codex_app_server_protocol::ResidencyRequirement { + match residency { + CoreResidencyRequirement::Us => codex_app_server_protocol::ResidencyRequirement::Us, + } +} + +fn map_network_requirements_to_api( + network: codex_config::NetworkRequirementsToml, +) -> NetworkRequirements { + let allowed_domains = network + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains); + let denied_domains = network + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::denied_domains); + let allow_unix_sockets = network + .unix_sockets + .as_ref() + .map(codex_config::NetworkUnixSocketPermissionsToml::allow_unix_sockets) + .filter(|entries| !entries.is_empty()); + + NetworkRequirements { + enabled: network.enabled, + http_port: network.http_port, + socks_port: network.socks_port, + allow_upstream_proxy: network.allow_upstream_proxy, + dangerously_allow_non_loopback_proxy: network.dangerously_allow_non_loopback_proxy, + dangerously_allow_all_unix_sockets: network.dangerously_allow_all_unix_sockets, + domains: network.domains.map(|domains| { + domains + .entries + .into_iter() + .map(|(pattern, permission)| { + (pattern, map_network_domain_permission_to_api(permission)) + }) + .collect() + }), + managed_allowed_domains_only: network.managed_allowed_domains_only, + allowed_domains, + denied_domains, + unix_sockets: network.unix_sockets.map(|unix_sockets| { + unix_sockets + .entries + .into_iter() + .map(|(path, permission)| { + (path, map_network_unix_socket_permission_to_api(permission)) + }) + .collect() + }), + allow_unix_sockets, + allow_local_binding: network.allow_local_binding, + } +} + +fn map_network_domain_permission_to_api( + permission: codex_config::NetworkDomainPermissionToml, +) -> NetworkDomainPermission { + match permission { + codex_config::NetworkDomainPermissionToml::Allow => NetworkDomainPermission::Allow, + codex_config::NetworkDomainPermissionToml::Deny => NetworkDomainPermission::Deny, + } +} + +fn map_network_unix_socket_permission_to_api( + permission: codex_config::NetworkUnixSocketPermissionToml, +) -> NetworkUnixSocketPermission { + match permission { + codex_config::NetworkUnixSocketPermissionToml::Allow => NetworkUnixSocketPermission::Allow, + codex_config::NetworkUnixSocketPermissionToml::None => NetworkUnixSocketPermission::None, + } +} + +fn map_error(err: ConfigManagerError) -> JSONRPCErrorError { + if let Some(code) = err.write_error_code() { + return config_write_error(code, err.to_string()); + } + + internal_error(err.to_string()) +} + +fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> JSONRPCErrorError { + let mut error = invalid_request(message); + error.data = Some(json!({ + "config_write_error_code": code, + })); + error +} + +#[cfg(test)] +mod tests { + use super::map_requirements_toml_to_api; + use codex_config::ConfigRequirementsToml; + use pretty_assertions::assert_eq; + + #[test] + fn requirements_api_includes_allow_managed_hooks_only() { + let mapped = map_requirements_toml_to_api(ConfigRequirementsToml { + allow_managed_hooks_only: Some(true), + ..ConfigRequirementsToml::default() + }); + + assert_eq!(mapped.allow_managed_hooks_only, Some(true)); + assert_eq!(mapped.hooks, None); + } +} diff --git a/codex-rs/app-server/src/request_processors/environment_processor.rs b/codex-rs/app-server/src/request_processors/environment_processor.rs new file mode 100644 index 0000000000..eb9b283f7b --- /dev/null +++ b/codex-rs/app-server/src/request_processors/environment_processor.rs @@ -0,0 +1,24 @@ +use super::*; + +#[derive(Clone)] +pub(crate) struct EnvironmentRequestProcessor { + environment_manager: Arc, +} + +impl EnvironmentRequestProcessor { + pub(crate) fn new(environment_manager: Arc) -> Self { + Self { + environment_manager, + } + } + + pub(crate) async fn environment_add( + &self, + params: EnvironmentAddParams, + ) -> Result, JSONRPCErrorError> { + self.environment_manager + .upsert_environment(params.environment_id, params.exec_server_url) + .map_err(|err| invalid_request(err.to_string()))?; + Ok(Some(EnvironmentAddResponse {}.into())) + } +} diff --git a/codex-rs/app-server/src/external_agent_config_api.rs b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs similarity index 59% rename from codex-rs/app-server/src/external_agent_config_api.rs rename to codex-rs/app-server/src/request_processors/external_agent_config_processor.rs index 5b6e341c47..21b7da679b 100644 --- a/codex-rs/app-server/src/external_agent_config_api.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_config_processor.rs @@ -1,15 +1,22 @@ +use std::sync::Arc; + use crate::config::external_agent_config::ExternalAgentConfigDetectOptions; use crate::config::external_agent_config::ExternalAgentConfigMigrationItem as CoreMigrationItem; use crate::config::external_agent_config::ExternalAgentConfigMigrationItemType as CoreMigrationItemType; use crate::config::external_agent_config::ExternalAgentConfigService; use crate::config::external_agent_config::NamedMigration as CoreNamedMigration; use crate::config::external_agent_config::PendingPluginImport; +use crate::config_manager::ConfigManager; use crate::error_code::internal_error; use crate::error_code::invalid_params; +use crate::outgoing_message::ConnectionRequestId; +use crate::outgoing_message::OutgoingMessageSender; use codex_app_server_protocol::CommandMigration; use codex_app_server_protocol::ExternalAgentConfigDetectParams; use codex_app_server_protocol::ExternalAgentConfigDetectResponse; +use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification; use codex_app_server_protocol::ExternalAgentConfigImportParams; +use codex_app_server_protocol::ExternalAgentConfigImportResponse; use codex_app_server_protocol::ExternalAgentConfigMigrationItem; use codex_app_server_protocol::ExternalAgentConfigMigrationItemType; use codex_app_server_protocol::HookMigration; @@ -17,30 +24,55 @@ use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::McpServerMigration; use codex_app_server_protocol::MigrationDetails; use codex_app_server_protocol::PluginsMigration; -use codex_app_server_protocol::SubagentMigration; +use codex_app_server_protocol::ServerNotification; +use codex_arg0::Arg0DispatchPaths; +use codex_core::StartThreadOptions; +use codex_core::ThreadManager; +use codex_core::config::ConfigOverrides; use codex_external_agent_sessions::ExternalAgentSessionMigration as CoreSessionMigration; +use codex_external_agent_sessions::ImportedExternalAgentSession; use codex_external_agent_sessions::PendingSessionImport; use codex_external_agent_sessions::prepare_validated_session_imports; use codex_external_agent_sessions::record_imported_session; use codex_protocol::ThreadId; +use codex_protocol::protocol::InitialHistory; +use codex_thread_store::ThreadMetadataPatch; use std::collections::HashSet; use std::path::PathBuf; -use std::sync::Arc; use tokio::sync::Semaphore; +use super::ConfigRequestProcessor; + #[derive(Clone)] -pub(crate) struct ExternalAgentConfigApi { +pub(crate) struct ExternalAgentConfigRequestProcessor { + outgoing: Arc, codex_home: PathBuf, migration_service: ExternalAgentConfigService, session_import_permits: Arc, + thread_manager: Arc, + config_manager: ConfigManager, + config_processor: ConfigRequestProcessor, + arg0_paths: Arg0DispatchPaths, } -impl ExternalAgentConfigApi { - pub(crate) fn new(codex_home: PathBuf) -> Self { +impl ExternalAgentConfigRequestProcessor { + pub(crate) fn new( + outgoing: Arc, + thread_manager: Arc, + config_manager: ConfigManager, + config_processor: ConfigRequestProcessor, + arg0_paths: Arg0DispatchPaths, + codex_home: PathBuf, + ) -> Self { Self { + outgoing, migration_service: ExternalAgentConfigService::new(codex_home.clone()), codex_home, session_import_permits: Arc::new(Semaphore::new(1)), + thread_manager, + config_manager, + config_processor, + arg0_paths, } } @@ -123,7 +155,7 @@ impl ExternalAgentConfigApi { subagents: details .subagents .into_iter() - .map(|subagent| SubagentMigration { + .map(|subagent| codex_app_server_protocol::SubagentMigration { name: subagent.name, }) .collect(), @@ -138,7 +170,171 @@ impl ExternalAgentConfigApi { }) } - pub(crate) fn validate_pending_session_imports( + pub(crate) async fn import( + &self, + request_id: ConnectionRequestId, + params: ExternalAgentConfigImportParams, + ) -> Result<(), JSONRPCErrorError> { + let needs_runtime_refresh = migration_items_need_runtime_refresh(¶ms.migration_items); + let has_migration_items = !params.migration_items.is_empty(); + let has_plugin_imports = params.migration_items.iter().any(|item| { + matches!( + item.item_type, + ExternalAgentConfigMigrationItemType::Plugins + ) + }); + let pending_session_imports = self.validate_pending_session_imports(¶ms)?; + let pending_plugin_imports = self.import_external_agent_config(params).await?; + if needs_runtime_refresh { + self.config_processor.handle_config_mutation().await; + } + self.outgoing + .send_response(request_id, ExternalAgentConfigImportResponse {}) + .await; + + if !has_migration_items { + return Ok(()); + } + + let has_background_imports = + !pending_plugin_imports.is_empty() || !pending_session_imports.is_empty(); + if !has_background_imports { + self.outgoing + .send_server_notification(ServerNotification::ExternalAgentConfigImportCompleted( + ExternalAgentConfigImportCompletedNotification {}, + )) + .await; + return Ok(()); + } + + let session_import_permits = Arc::clone(&self.session_import_permits); + let session_processor = self.clone(); + let plugin_processor = self.clone(); + let outgoing = Arc::clone(&self.outgoing); + let thread_manager = Arc::clone(&self.thread_manager); + tokio::spawn(async move { + let session_imports = async move { + if !pending_session_imports.is_empty() { + let Ok(_session_import_permit) = session_import_permits.acquire_owned().await + else { + return; + }; + let pending_session_imports = session_processor + .prepare_validated_session_imports(pending_session_imports); + for pending_session_import in pending_session_imports { + match session_processor + .import_external_agent_session(pending_session_import.session) + .await + { + Ok(imported_thread_id) => { + session_processor.record_imported_session( + &pending_session_import.source_path, + imported_thread_id, + ); + } + Err(error) => { + tracing::warn!( + error = %error.message, + path = %pending_session_import.source_path.display(), + "external agent session import failed" + ); + } + } + } + } + }; + let plugin_imports = async move { + for pending_plugin_import in pending_plugin_imports { + match plugin_processor + .complete_pending_plugin_import(pending_plugin_import) + .await + { + Ok(()) => {} + Err(error) => { + tracing::warn!( + error = %error.message, + "external agent config plugin import failed" + ); + } + } + } + }; + tokio::join!(session_imports, plugin_imports); + if has_plugin_imports { + thread_manager.plugins_manager().clear_cache(); + thread_manager.skills_manager().clear_cache(); + } + outgoing + .send_server_notification(ServerNotification::ExternalAgentConfigImportCompleted( + ExternalAgentConfigImportCompletedNotification {}, + )) + .await; + }); + + Ok(()) + } + + async fn import_external_agent_session( + &self, + session: ImportedExternalAgentSession, + ) -> Result { + let ImportedExternalAgentSession { + cwd, + title, + rollout_items, + } = session; + let config = self + .config_manager + .load_with_overrides( + /*request_overrides*/ None, + ConfigOverrides { + cwd: Some(PathBuf::from(cwd.to_string_lossy().into_owned())), + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), + ..Default::default() + }, + ) + .await + .map_err(|err| { + internal_error(format!("failed to load imported session config: {err}")) + })?; + let environments = self + .thread_manager + .default_environment_selections(&config.cwd); + let imported_thread = self + .thread_manager + .start_thread_with_options(StartThreadOptions { + config, + initial_history: InitialHistory::Forked(rollout_items), + session_source: None, + thread_source: None, + dynamic_tools: Vec::new(), + persist_extended_history: false, + metrics_service_name: None, + parent_trace: None, + environments, + }) + .await + .map_err(|err| internal_error(format!("failed to import session: {err}")))?; + if let Some(title) = title + && let Some(name) = codex_core::util::normalize_thread_name(&title) + { + imported_thread + .thread + .update_thread_metadata( + ThreadMetadataPatch { + name: Some(Some(name)), + ..Default::default() + }, + /*include_archived*/ false, + ) + .await + .map_err(|err| internal_error(format!("failed to name imported session: {err}")))?; + } + Ok(imported_thread.thread_id) + } + + fn validate_pending_session_imports( &self, params: &ExternalAgentConfigImportParams, ) -> Result, JSONRPCErrorError> { @@ -176,22 +372,14 @@ impl ExternalAgentConfigApi { Ok(selected_sessions) } - pub(crate) fn prepare_validated_session_imports( + fn prepare_validated_session_imports( &self, sessions: Vec, ) -> Vec { prepare_validated_session_imports(&self.codex_home, sessions) } - pub(crate) fn session_import_permits(&self) -> Arc { - Arc::clone(&self.session_import_permits) - } - - pub(crate) fn record_imported_session( - &self, - source_path: &std::path::Path, - imported_thread_id: ThreadId, - ) { + fn record_imported_session(&self, source_path: &std::path::Path, imported_thread_id: ThreadId) { if let Err(err) = record_imported_session(&self.codex_home, source_path, imported_thread_id) { tracing::warn!( @@ -202,7 +390,7 @@ impl ExternalAgentConfigApi { } } - pub(crate) async fn import( + async fn import_external_agent_config( &self, params: ExternalAgentConfigImportParams, ) -> Result, JSONRPCErrorError> { @@ -297,7 +485,7 @@ impl ExternalAgentConfigApi { .map_err(|err| internal_error(err.to_string())) } - pub(crate) async fn complete_pending_plugin_import( + async fn complete_pending_plugin_import( &self, pending_plugin_import: PendingPluginImport, ) -> Result<(), JSONRPCErrorError> { @@ -312,9 +500,27 @@ impl ExternalAgentConfigApi { } } +fn migration_items_need_runtime_refresh(items: &[ExternalAgentConfigMigrationItem]) -> bool { + items.iter().any(|item| { + matches!( + item.item_type, + ExternalAgentConfigMigrationItemType::Config + | ExternalAgentConfigMigrationItemType::Skills + | ExternalAgentConfigMigrationItemType::McpServerConfig + | ExternalAgentConfigMigrationItemType::Hooks + | ExternalAgentConfigMigrationItemType::Commands + | ExternalAgentConfigMigrationItemType::Plugins + ) + }) +} + fn session_not_detected_error(path: &std::path::Path) -> JSONRPCErrorError { invalid_params(format!( "external agent session was not detected for import: {}", path.display() )) } + +#[cfg(test)] +#[path = "external_agent_config_processor_tests.rs"] +mod external_agent_config_processor_tests; diff --git a/codex-rs/app-server/src/request_processors/external_agent_config_processor_tests.rs b/codex-rs/app-server/src/request_processors/external_agent_config_processor_tests.rs new file mode 100644 index 0000000000..fb1b8ee6c1 --- /dev/null +++ b/codex-rs/app-server/src/request_processors/external_agent_config_processor_tests.rs @@ -0,0 +1,37 @@ +use super::*; + +fn migration_item( + item_type: ExternalAgentConfigMigrationItemType, +) -> ExternalAgentConfigMigrationItem { + ExternalAgentConfigMigrationItem { + item_type, + description: String::new(), + cwd: None, + details: None, + } +} + +#[test] +fn migration_items_that_update_runtime_sources_trigger_refresh() { + assert!(migration_items_need_runtime_refresh(&[migration_item( + ExternalAgentConfigMigrationItemType::Config, + )])); + assert!(migration_items_need_runtime_refresh(&[migration_item( + ExternalAgentConfigMigrationItemType::Skills, + )])); + assert!(migration_items_need_runtime_refresh(&[migration_item( + ExternalAgentConfigMigrationItemType::McpServerConfig, + )])); + assert!(migration_items_need_runtime_refresh(&[migration_item( + ExternalAgentConfigMigrationItemType::Hooks, + )])); + assert!(migration_items_need_runtime_refresh(&[migration_item( + ExternalAgentConfigMigrationItemType::Commands, + )])); + assert!(migration_items_need_runtime_refresh(&[migration_item( + ExternalAgentConfigMigrationItemType::Plugins, + )])); + assert!(!migration_items_need_runtime_refresh(&[migration_item( + ExternalAgentConfigMigrationItemType::Sessions, + )])); +} diff --git a/codex-rs/app-server/src/request_processors/feedback_processor.rs b/codex-rs/app-server/src/request_processors/feedback_processor.rs new file mode 100644 index 0000000000..5b9039b57d --- /dev/null +++ b/codex-rs/app-server/src/request_processors/feedback_processor.rs @@ -0,0 +1,252 @@ +use super::*; + +#[derive(Clone)] +pub(crate) struct FeedbackRequestProcessor { + auth_manager: Arc, + thread_manager: Arc, + config: Arc, + feedback: CodexFeedback, + log_db: Option, + state_db: Option, +} + +impl FeedbackRequestProcessor { + pub(crate) fn new( + auth_manager: Arc, + thread_manager: Arc, + config: Arc, + feedback: CodexFeedback, + log_db: Option, + state_db: Option, + ) -> Self { + Self { + auth_manager, + thread_manager, + config, + feedback, + log_db, + state_db, + } + } + + pub(crate) async fn feedback_upload( + &self, + params: FeedbackUploadParams, + ) -> Result, JSONRPCErrorError> { + self.upload_feedback_response(params) + .await + .map(|response| Some(response.into())) + } + + async fn upload_feedback_response( + &self, + params: FeedbackUploadParams, + ) -> Result { + if !self.config.feedback_enabled { + return Err(invalid_request( + "sending feedback is disabled by configuration", + )); + } + + let FeedbackUploadParams { + classification, + reason, + thread_id, + include_logs, + extra_log_files, + tags, + } = params; + + let conversation_id = match thread_id.as_deref() { + Some(thread_id) => match ThreadId::from_string(thread_id) { + Ok(conversation_id) => Some(conversation_id), + Err(err) => return Err(invalid_request(format!("invalid thread id: {err}"))), + }, + None => None, + }; + + if let Some(chatgpt_user_id) = self + .auth_manager + .auth_cached() + .and_then(|auth| auth.get_chatgpt_user_id()) + { + tracing::info!(target: "feedback_tags", chatgpt_user_id); + } + if let Some(account_id) = self + .auth_manager + .auth_cached() + .and_then(|auth| auth.get_account_id()) + { + tracing::info!(target: "feedback_tags", account_id); + } + let snapshot = self.feedback.snapshot(conversation_id); + let thread_id = snapshot.thread_id.clone(); + let (feedback_thread_ids, sqlite_feedback_logs, state_db_ctx) = if include_logs { + if let Some(log_db) = self.log_db.as_ref() { + log_db.flush().await; + } + let state_db_ctx = self.state_db.clone(); + let feedback_thread_ids = match conversation_id { + Some(conversation_id) => match self + .thread_manager + .list_agent_subtree_thread_ids(conversation_id) + .await + { + Ok(thread_ids) => thread_ids, + Err(err) => { + warn!( + "failed to list feedback subtree for thread_id={conversation_id}: {err}" + ); + let mut thread_ids = vec![conversation_id]; + if let Some(state_db_ctx) = state_db_ctx.as_ref() { + for status in [ + codex_state::DirectionalThreadSpawnEdgeStatus::Open, + codex_state::DirectionalThreadSpawnEdgeStatus::Closed, + ] { + match state_db_ctx + .list_thread_spawn_descendants_with_status( + conversation_id, + status, + ) + .await + { + Ok(descendant_ids) => thread_ids.extend(descendant_ids), + Err(err) => warn!( + "failed to list persisted feedback subtree for thread_id={conversation_id}: {err}" + ), + } + } + } + thread_ids + } + }, + None => Vec::new(), + }; + let sqlite_feedback_logs = if let Some(state_db_ctx) = state_db_ctx.as_ref() + && !feedback_thread_ids.is_empty() + { + let thread_id_texts = feedback_thread_ids + .iter() + .map(ToString::to_string) + .collect::>(); + let thread_id_refs = thread_id_texts + .iter() + .map(String::as_str) + .collect::>(); + match state_db_ctx + .query_feedback_logs_for_threads(&thread_id_refs) + .await + { + Ok(logs) if logs.is_empty() => None, + Ok(logs) => Some(logs), + Err(err) => { + let thread_ids = thread_id_texts.join(", "); + warn!( + "failed to query feedback logs from sqlite for thread_ids=[{thread_ids}]: {err}" + ); + None + } + } + } else { + None + }; + (feedback_thread_ids, sqlite_feedback_logs, state_db_ctx) + } else { + (Vec::new(), None, None) + }; + + let mut attachment_paths = Vec::new(); + let mut seen_attachment_paths = HashSet::new(); + if include_logs { + for feedback_thread_id in &feedback_thread_ids { + let Some(rollout_path) = self + .resolve_rollout_path(*feedback_thread_id, state_db_ctx.as_ref()) + .await + else { + continue; + }; + if seen_attachment_paths.insert(rollout_path.clone()) { + attachment_paths.push(FeedbackAttachmentPath { + path: rollout_path, + attachment_filename_override: None, + }); + } + } + if let Some(conversation_id) = conversation_id + && let Ok(conversation) = self.thread_manager.get_thread(conversation_id).await + && let Some(guardian_rollout_path) = + conversation.guardian_trunk_rollout_path().await + && seen_attachment_paths.insert(guardian_rollout_path.clone()) + { + attachment_paths.push(FeedbackAttachmentPath { + path: guardian_rollout_path, + attachment_filename_override: Some(auto_review_rollout_filename( + conversation_id, + )), + }); + } + } + if let Some(extra_log_files) = extra_log_files { + for extra_log_file in extra_log_files { + if seen_attachment_paths.insert(extra_log_file.clone()) { + attachment_paths.push(FeedbackAttachmentPath { + path: extra_log_file, + attachment_filename_override: None, + }); + } + } + } + + let session_source = self.thread_manager.session_source(); + + let upload_result = tokio::task::spawn_blocking(move || { + snapshot.upload_feedback(FeedbackUploadOptions { + classification: &classification, + reason: reason.as_deref(), + tags: tags.as_ref(), + include_logs, + extra_attachment_paths: &attachment_paths, + session_source: Some(session_source), + logs_override: sqlite_feedback_logs, + }) + }) + .await; + + let upload_result = match upload_result { + Ok(result) => result, + Err(join_err) => { + return Err(internal_error(format!( + "failed to upload feedback: {join_err}" + ))); + } + }; + + upload_result.map_err(|err| internal_error(format!("failed to upload feedback: {err}")))?; + Ok(FeedbackUploadResponse { thread_id }) + } + + async fn resolve_rollout_path( + &self, + conversation_id: ThreadId, + state_db_ctx: Option<&StateDbHandle>, + ) -> Option { + if let Ok(conversation) = self.thread_manager.get_thread(conversation_id).await + && let Some(rollout_path) = conversation.rollout_path() + { + return Some(rollout_path); + } + + let state_db_ctx = state_db_ctx?; + state_db_ctx + .find_rollout_path_by_id(conversation_id, /*archived_only*/ None) + .await + .unwrap_or_else(|err| { + warn!("failed to resolve rollout path for thread_id={conversation_id}: {err}"); + None + }) + } +} + +fn auto_review_rollout_filename(thread_id: ThreadId) -> String { + format!("auto-review-rollout-{thread_id}.jsonl") +} diff --git a/codex-rs/app-server/src/fs_api.rs b/codex-rs/app-server/src/request_processors/fs_processor.rs similarity index 80% rename from codex-rs/app-server/src/fs_api.rs rename to codex-rs/app-server/src/request_processors/fs_processor.rs index 203b053e5e..01b9b20bfd 100644 --- a/codex-rs/app-server/src/fs_api.rs +++ b/codex-rs/app-server/src/request_processors/fs_processor.rs @@ -1,5 +1,7 @@ use crate::error_code::internal_error; use crate::error_code::invalid_request; +use crate::fs_watch::FsWatchManager; +use crate::outgoing_message::ConnectionId; use base64::Engine; use base64::engine::general_purpose::STANDARD; use codex_app_server_protocol::FsCopyParams; @@ -15,6 +17,10 @@ use codex_app_server_protocol::FsReadFileParams; use codex_app_server_protocol::FsReadFileResponse; use codex_app_server_protocol::FsRemoveParams; use codex_app_server_protocol::FsRemoveResponse; +use codex_app_server_protocol::FsUnwatchParams; +use codex_app_server_protocol::FsUnwatchResponse; +use codex_app_server_protocol::FsWatchParams; +use codex_app_server_protocol::FsWatchResponse; use codex_app_server_protocol::FsWriteFileParams; use codex_app_server_protocol::FsWriteFileResponse; use codex_app_server_protocol::JSONRPCErrorError; @@ -26,13 +32,24 @@ use std::io; use std::sync::Arc; #[derive(Clone)] -pub(crate) struct FsApi { +pub(crate) struct FsRequestProcessor { file_system: Arc, + fs_watch_manager: FsWatchManager, } -impl FsApi { - pub(crate) fn new(file_system: Arc) -> Self { - Self { file_system } +impl FsRequestProcessor { + pub(crate) fn new( + file_system: Arc, + fs_watch_manager: FsWatchManager, + ) -> Self { + Self { + file_system, + fs_watch_manager, + } + } + + pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) { + self.fs_watch_manager.connection_closed(connection_id).await; } pub(crate) async fn read_file( @@ -156,9 +173,25 @@ impl FsApi { .map_err(map_fs_error)?; Ok(FsCopyResponse {}) } + + pub(crate) async fn watch( + &self, + connection_id: ConnectionId, + params: FsWatchParams, + ) -> Result { + self.fs_watch_manager.watch(connection_id, params).await + } + + pub(crate) async fn unwatch( + &self, + connection_id: ConnectionId, + params: FsUnwatchParams, + ) -> Result { + self.fs_watch_manager.unwatch(connection_id, params).await + } } -pub(crate) fn map_fs_error(err: io::Error) -> JSONRPCErrorError { +fn map_fs_error(err: io::Error) -> JSONRPCErrorError { if err.kind() == io::ErrorKind::InvalidInput { invalid_request(err.to_string()) } else { diff --git a/codex-rs/app-server/src/request_processors/git_processor.rs b/codex-rs/app-server/src/request_processors/git_processor.rs new file mode 100644 index 0000000000..b7c5fad610 --- /dev/null +++ b/codex-rs/app-server/src/request_processors/git_processor.rs @@ -0,0 +1,36 @@ +use super::*; + +#[derive(Clone)] +pub(crate) struct GitRequestProcessor; + +impl GitRequestProcessor { + pub(crate) fn new() -> Self { + Self + } + + pub(crate) async fn git_diff_to_remote( + &self, + params: GitDiffToRemoteParams, + ) -> Result, JSONRPCErrorError> { + self.git_diff_to_origin(params.cwd) + .await + .map(|response| Some(response.into())) + } + + async fn git_diff_to_origin( + &self, + cwd: PathBuf, + ) -> Result { + git_diff_to_remote(&cwd) + .await + .map(|value| GitDiffToRemoteResponse { + sha: value.sha, + diff: value.diff, + }) + .ok_or_else(|| { + invalid_request(format!( + "failed to compute git diff to remote for cwd: {cwd:?}" + )) + }) + } +} diff --git a/codex-rs/app-server/src/request_processors/initialize_processor.rs b/codex-rs/app-server/src/request_processors/initialize_processor.rs new file mode 100644 index 0000000000..c33af189cf --- /dev/null +++ b/codex-rs/app-server/src/request_processors/initialize_processor.rs @@ -0,0 +1,191 @@ +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; + +use axum::http::HeaderValue; +use codex_analytics::AppServerRpcTransport; +use codex_login::default_client::SetOriginatorError; +use codex_login::default_client::USER_AGENT_SUFFIX; +use codex_login::default_client::get_codex_user_agent; +use codex_login::default_client::set_default_client_residency_requirement; +use codex_login::default_client::set_default_originator; + +use super::*; +use crate::message_processor::ConnectionSessionState; +use crate::message_processor::InitializedConnectionSessionState; + +const DAEMON_PROBE_CLIENT_NAME: &str = "codex_app_server_daemon"; + +#[derive(Clone)] +pub(crate) struct InitializeRequestProcessor { + outgoing: Arc, + analytics_events_client: AnalyticsEventsClient, + config: Arc, + config_warnings: Arc>, + rpc_transport: AppServerRpcTransport, +} + +impl InitializeRequestProcessor { + pub(crate) fn new( + outgoing: Arc, + analytics_events_client: AnalyticsEventsClient, + config: Arc, + config_warnings: Vec, + rpc_transport: AppServerRpcTransport, + ) -> Self { + Self { + outgoing, + analytics_events_client, + config, + config_warnings: Arc::new(config_warnings), + rpc_transport, + } + } + + pub(crate) async fn initialize( + &self, + connection_id: ConnectionId, + request_id: RequestId, + params: InitializeParams, + session: &ConnectionSessionState, + // `Some(...)` means the caller wants initialize to immediately mark the + // connection outbound-ready. Websocket JSON-RPC calls pass `None` so + // lib.rs can deliver connection-scoped initialize notifications first. + outbound_initialized: Option<&AtomicBool>, + ) -> Result { + let connection_request_id = ConnectionRequestId { + connection_id, + request_id, + }; + if session.initialized() { + return Err(invalid_request("Already initialized")); + } + + // TODO(maxj): Revisit capability scoping for `experimental_api_enabled`. + // Current behavior is per-connection. Reviewer feedback notes this can + // create odd cross-client behavior (for example dynamic tool calls on a + // 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, request_attestation, opt_out_notification_methods) = + match params.capabilities { + Some(capabilities) => ( + capabilities.experimental_api, + capabilities.request_attestation, + capabilities + .opt_out_notification_methods + .unwrap_or_default(), + ), + None => (false, false, Vec::new()), + }; + let ClientInfo { + name, + title: _title, + version, + } = params.client_info; + // Validate before committing; set_default_originator validates while + // mutating process-global metadata. + if HeaderValue::from_str(&name).is_err() { + return Err(invalid_request(format!( + "Invalid clientInfo.name: '{name}'. Must be a valid HTTP header value." + ))); + } + let originator = name.clone(); + let user_agent_suffix = format!("{name}; {version}"); + let mutates_global_identity = name != DAEMON_PROBE_CLIENT_NAME; + let codex_home = self.config.codex_home.clone(); + if session + .initialize(InitializedConnectionSessionState { + experimental_api_enabled, + opted_out_notification_methods: opt_out_notification_methods.into_iter().collect(), + app_server_client_name: name.clone(), + client_version: version, + request_attestation, + }) + .is_err() + { + return Err(invalid_request("Already initialized")); + } + + if mutates_global_identity { + // Only real client initialization may mutate process-global client metadata. + if let Err(error) = set_default_originator(originator.clone()) { + match error { + SetOriginatorError::InvalidHeaderValue => { + tracing::warn!( + client_info_name = %name, + "validated clientInfo.name was rejected while setting originator" + ); + } + SetOriginatorError::AlreadyInitialized => { + // No-op. This is expected to happen if the originator is already set via env var. + // TODO(owen): Once we remove support for CODEX_INTERNAL_ORIGINATOR_OVERRIDE, + // this will be an unexpected state and we can return a JSON-RPC error indicating + // internal server error. + } + } + } + } + self.analytics_events_client.track_initialize( + connection_id.0, + analytics_initialize_params, + originator, + self.rpc_transport, + ); + set_default_client_residency_requirement(self.config.enforce_residency.value()); + if mutates_global_identity && let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() { + *suffix = Some(user_agent_suffix); + } + + let user_agent = get_codex_user_agent(); + let response = InitializeResponse { + user_agent, + codex_home, + platform_family: std::env::consts::FAMILY.to_string(), + platform_os: std::env::consts::OS.to_string(), + }; + + self.outgoing + .send_response(connection_request_id, response) + .await; + + if let Some(outbound_initialized) = outbound_initialized { + outbound_initialized.store(true, Ordering::Release); + return Ok(true); + } + + Ok(false) + } + + pub(crate) async fn send_initialize_notifications_to_connection( + &self, + connection_id: ConnectionId, + ) { + for notification in self.config_warnings.iter().cloned() { + self.outgoing + .send_server_notification_to_connections( + &[connection_id], + ServerNotification::ConfigWarning(notification), + ) + .await; + } + } + + pub(crate) async fn send_initialize_notifications(&self) { + for notification in self.config_warnings.iter().cloned() { + self.outgoing + .send_server_notification(ServerNotification::ConfigWarning(notification)) + .await; + } + } + + pub(crate) fn track_initialized_request( + &self, + connection_id: ConnectionId, + request_id: RequestId, + request: &ClientRequest, + ) { + self.analytics_events_client + .track_request(connection_id.0, request_id, request); + } +} diff --git a/codex-rs/app-server/src/request_processors/marketplace_processor.rs b/codex-rs/app-server/src/request_processors/marketplace_processor.rs new file mode 100644 index 0000000000..1a09507418 --- /dev/null +++ b/codex-rs/app-server/src/request_processors/marketplace_processor.rs @@ -0,0 +1,137 @@ +use super::*; + +#[derive(Clone)] +pub(crate) struct MarketplaceRequestProcessor { + config: Arc, + config_manager: ConfigManager, + thread_manager: Arc, +} + +impl MarketplaceRequestProcessor { + pub(crate) fn new( + config: Arc, + config_manager: ConfigManager, + thread_manager: Arc, + ) -> Self { + Self { + config, + config_manager, + thread_manager, + } + } + + pub(crate) async fn marketplace_add( + &self, + params: MarketplaceAddParams, + ) -> Result, JSONRPCErrorError> { + self.marketplace_add_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn marketplace_remove( + &self, + params: MarketplaceRemoveParams, + ) -> Result, JSONRPCErrorError> { + self.marketplace_remove_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn marketplace_upgrade( + &self, + params: MarketplaceUpgradeParams, + ) -> Result, JSONRPCErrorError> { + self.marketplace_upgrade_response_inner(params) + .await + .map(|response| Some(response.into())) + } + + async fn marketplace_remove_inner( + &self, + params: MarketplaceRemoveParams, + ) -> Result { + remove_marketplace( + self.config.codex_home.to_path_buf(), + CoreMarketplaceRemoveRequest { + marketplace_name: params.marketplace_name, + }, + ) + .await + .map(|outcome| MarketplaceRemoveResponse { + marketplace_name: outcome.marketplace_name, + installed_root: outcome.removed_installed_root, + }) + .map_err(|err| match err { + MarketplaceRemoveError::InvalidRequest(message) => invalid_request(message), + MarketplaceRemoveError::Internal(message) => internal_error(message), + }) + } + + async fn marketplace_upgrade_response_inner( + &self, + params: MarketplaceUpgradeParams, + ) -> Result { + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let plugins_manager = self.thread_manager.plugins_manager(); + let MarketplaceUpgradeParams { marketplace_name } = params; + let plugins_input = config.plugins_config_input(); + + let outcome = tokio::task::spawn_blocking(move || { + plugins_manager.upgrade_configured_marketplaces_for_config( + &plugins_input, + marketplace_name.as_deref(), + ) + }) + .await + .map_err(|err| internal_error(format!("failed to upgrade marketplaces: {err}")))? + .map_err(invalid_request)?; + + Ok(MarketplaceUpgradeResponse { + selected_marketplaces: outcome.selected_marketplaces, + upgraded_roots: outcome.upgraded_roots, + errors: outcome + .errors + .into_iter() + .map(|err| MarketplaceUpgradeErrorInfo { + marketplace_name: err.marketplace_name, + message: err.message, + }) + .collect(), + }) + } + + async fn marketplace_add_inner( + &self, + params: MarketplaceAddParams, + ) -> Result { + add_marketplace_to_codex_home( + self.config.codex_home.to_path_buf(), + MarketplaceAddRequest { + source: params.source, + ref_name: params.ref_name, + sparse_paths: params.sparse_paths.unwrap_or_default(), + }, + ) + .await + .map(|outcome| MarketplaceAddResponse { + marketplace_name: outcome.marketplace_name, + installed_root: outcome.installed_root, + already_added: outcome.already_added, + }) + .map_err(|err| match err { + MarketplaceAddError::InvalidRequest(message) => invalid_request(message), + MarketplaceAddError::Internal(message) => internal_error(message), + }) + } + + async fn load_latest_config( + &self, + fallback_cwd: Option, + ) -> Result { + self.config_manager + .load_latest_config(fallback_cwd) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}"))) + } +} diff --git a/codex-rs/app-server/src/request_processors/mcp_processor.rs b/codex-rs/app-server/src/request_processors/mcp_processor.rs new file mode 100644 index 0000000000..812108a15c --- /dev/null +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -0,0 +1,459 @@ +use super::*; + +const MCP_TOOL_THREAD_ID_META_KEY: &str = "threadId"; + +#[derive(Clone)] +pub(crate) struct McpRequestProcessor { + auth_manager: Arc, + thread_manager: Arc, + outgoing: Arc, + config_manager: ConfigManager, +} + +impl McpRequestProcessor { + pub(crate) fn new( + auth_manager: Arc, + thread_manager: Arc, + outgoing: Arc, + config_manager: ConfigManager, + ) -> Self { + Self { + auth_manager, + thread_manager, + outgoing, + config_manager, + } + } + + pub(crate) async fn mcp_server_oauth_login( + &self, + params: McpServerOauthLoginParams, + ) -> Result, JSONRPCErrorError> { + self.mcp_server_oauth_login_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn mcp_server_refresh( + &self, + params: Option<()>, + ) -> Result, JSONRPCErrorError> { + self.mcp_server_refresh_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn mcp_server_status_list( + &self, + request_id: &ConnectionRequestId, + params: ListMcpServerStatusParams, + ) -> Result, JSONRPCErrorError> { + self.list_mcp_server_status(request_id, params) + .await + .map(|()| None) + } + + pub(crate) async fn mcp_resource_read( + &self, + request_id: &ConnectionRequestId, + params: McpResourceReadParams, + ) -> Result, JSONRPCErrorError> { + self.read_mcp_resource(request_id, params) + .await + .map(|()| None) + } + + pub(crate) async fn mcp_server_tool_call( + &self, + request_id: &ConnectionRequestId, + params: McpServerToolCallParams, + ) -> Result, JSONRPCErrorError> { + self.call_mcp_server_tool(request_id, params) + .await + .map(|()| None) + } + + async fn mcp_server_refresh_response( + &self, + _params: Option<()>, + ) -> Result { + crate::mcp_refresh::queue_strict_refresh(&self.thread_manager, &self.config_manager) + .await + .map_err(|err| internal_error(format!("failed to refresh MCP servers: {err}")))?; + Ok(McpServerRefreshResponse {}) + } + + async fn load_latest_config( + &self, + fallback_cwd: Option, + ) -> Result { + self.config_manager + .load_latest_config(fallback_cwd) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}"))) + } + + async fn load_thread( + &self, + thread_id: &str, + ) -> Result<(ThreadId, Arc), JSONRPCErrorError> { + let thread_id = ThreadId::from_string(thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + + let thread = self + .thread_manager + .get_thread(thread_id) + .await + .map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?; + + Ok((thread_id, thread)) + } + + async fn mcp_server_oauth_login_response( + &self, + params: McpServerOauthLoginParams, + ) -> Result { + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let McpServerOauthLoginParams { + name, + scopes, + timeout_secs, + } = params; + + let configured_servers = self + .thread_manager + .mcp_manager() + .configured_servers(&config) + .await; + let Some(server) = configured_servers.get(&name) else { + return Err(invalid_request(format!( + "No MCP server named '{name}' found." + ))); + }; + + let (url, http_headers, env_http_headers) = match &server.transport { + McpServerTransportConfig::StreamableHttp { + url, + http_headers, + env_http_headers, + .. + } => (url.clone(), http_headers.clone(), env_http_headers.clone()), + _ => { + return Err(invalid_request( + "OAuth login is only supported for streamable HTTP servers.", + )); + } + }; + + let discovered_scopes = if scopes.is_none() && server.scopes.is_none() { + discover_supported_scopes(&server.transport).await + } else { + None + }; + let resolved_scopes = + resolve_oauth_scopes(scopes, server.scopes.clone(), discovered_scopes); + + let handle = perform_oauth_login_return_url( + &name, + &url, + config.mcp_oauth_credentials_store_mode, + http_headers, + env_http_headers, + &resolved_scopes.scopes, + server.oauth_resource.as_deref(), + timeout_secs, + config.mcp_oauth_callback_port, + config.mcp_oauth_callback_url.as_deref(), + ) + .await + .map_err(|err| internal_error(format!("failed to login to MCP server '{name}': {err}")))?; + let authorization_url = handle.authorization_url().to_string(); + let notification_name = name.clone(); + let outgoing = Arc::clone(&self.outgoing); + + tokio::spawn(async move { + let (success, error) = match handle.wait().await { + Ok(()) => (true, None), + Err(err) => (false, Some(err.to_string())), + }; + + let notification = ServerNotification::McpServerOauthLoginCompleted( + McpServerOauthLoginCompletedNotification { + name: notification_name, + success, + error, + }, + ); + outgoing.send_server_notification(notification).await; + }); + + Ok(McpServerOauthLoginResponse { authorization_url }) + } + + async fn list_mcp_server_status( + &self, + request_id: &ConnectionRequestId, + params: ListMcpServerStatusParams, + ) -> Result<(), JSONRPCErrorError> { + let request = request_id.clone(); + + let outgoing = Arc::clone(&self.outgoing); + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let mcp_config = config + .to_mcp_config(self.thread_manager.plugins_manager().as_ref()) + .await; + let auth = self.auth_manager.auth().await; + let environment_manager = self.thread_manager.environment_manager(); + let runtime_environment = match environment_manager.default_environment() { + Some(environment) => { + // Status listing has no turn cwd. This fallback is used only + // by executor-backed stdio MCPs whose config omits `cwd`. + McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()) + } + None => McpRuntimeEnvironment::new( + environment_manager.local_environment(), + config.cwd.to_path_buf(), + ), + }; + + tokio::spawn(async move { + Self::list_mcp_server_status_task( + outgoing, + request, + params, + config, + mcp_config, + auth, + runtime_environment, + ) + .await; + }); + Ok(()) + } + + async fn list_mcp_server_status_task( + outgoing: Arc, + request_id: ConnectionRequestId, + params: ListMcpServerStatusParams, + config: Config, + mcp_config: codex_mcp::McpConfig, + auth: Option, + runtime_environment: McpRuntimeEnvironment, + ) { + let result = Self::list_mcp_server_status_response( + request_id.request_id.to_string(), + params, + config, + mcp_config, + auth, + runtime_environment, + ) + .await; + outgoing.send_result(request_id, result).await; + } + + async fn list_mcp_server_status_response( + request_id: String, + params: ListMcpServerStatusParams, + config: Config, + mcp_config: codex_mcp::McpConfig, + auth: Option, + runtime_environment: McpRuntimeEnvironment, + ) -> Result { + let detail = match params.detail.unwrap_or(McpServerStatusDetail::Full) { + McpServerStatusDetail::Full => McpSnapshotDetail::Full, + McpServerStatusDetail::ToolsAndAuthOnly => McpSnapshotDetail::ToolsAndAuthOnly, + }; + + let snapshot = collect_mcp_server_status_snapshot_with_detail( + &mcp_config, + auth.as_ref(), + request_id, + runtime_environment, + detail, + ) + .await; + + let effective_servers = effective_mcp_servers(&mcp_config, auth.as_ref()); + let McpServerStatusSnapshot { + tools_by_server, + resources, + resource_templates, + auth_statuses, + } = snapshot; + + let mut server_names: Vec = config + .mcp_servers + .keys() + .cloned() + // Include runtime-added/plugin MCP servers that are present in the + // effective runtime config even when they are not user-declared in + // `config.mcp_servers`. + .chain(effective_servers.keys().cloned()) + .chain(auth_statuses.keys().cloned()) + .chain(resources.keys().cloned()) + .chain(resource_templates.keys().cloned()) + .collect(); + server_names.sort(); + server_names.dedup(); + + let total = server_names.len(); + let limit = params.limit.unwrap_or(total as u32).max(1) as usize; + let effective_limit = limit.min(total); + let start = match params.cursor { + Some(cursor) => match cursor.parse::() { + Ok(idx) => idx, + Err(_) => return Err(invalid_request(format!("invalid cursor: {cursor}"))), + }, + None => 0, + }; + + if start > total { + return Err(invalid_request(format!( + "cursor {start} exceeds total MCP servers {total}" + ))); + } + + let end = start.saturating_add(effective_limit).min(total); + + let data: Vec = server_names[start..end] + .iter() + .map(|name| McpServerStatus { + name: name.clone(), + tools: tools_by_server.get(name).cloned().unwrap_or_default(), + resources: resources.get(name).cloned().unwrap_or_default(), + resource_templates: resource_templates.get(name).cloned().unwrap_or_default(), + auth_status: auth_statuses + .get(name) + .cloned() + .unwrap_or(CoreMcpAuthStatus::Unsupported) + .into(), + }) + .collect(); + + let next_cursor = if end < total { + Some(end.to_string()) + } else { + None + }; + + Ok(ListMcpServerStatusResponse { data, next_cursor }) + } + + async fn read_mcp_resource( + &self, + request_id: &ConnectionRequestId, + params: McpResourceReadParams, + ) -> Result<(), JSONRPCErrorError> { + let outgoing = Arc::clone(&self.outgoing); + let McpResourceReadParams { + thread_id, + server, + uri, + } = params; + + if let Some(thread_id) = thread_id { + let (_, thread) = self.load_thread(&thread_id).await?; + let request_id = request_id.clone(); + + tokio::spawn(async move { + let result = thread.read_mcp_resource(&server, &uri).await; + Self::send_mcp_resource_read_response(outgoing, request_id, result).await; + }); + return Ok(()); + } + + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let mcp_config = config + .to_mcp_config(self.thread_manager.plugins_manager().as_ref()) + .await; + let auth = self.auth_manager.auth().await; + let runtime_environment = { + let environment_manager = self.thread_manager.environment_manager(); + let environment = environment_manager + .default_environment() + .unwrap_or_else(|| environment_manager.local_environment()); + // Resource reads without a thread have no turn cwd. This fallback + // is used only by executor-backed stdio MCPs whose config omits `cwd`. + McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()) + }; + let request_id = request_id.clone(); + + tokio::spawn(async move { + let result = read_mcp_resource_without_thread( + &mcp_config, + auth.as_ref(), + runtime_environment, + &server, + &uri, + ) + .await + .and_then(|result| serde_json::to_value(result).map_err(anyhow::Error::from)); + Self::send_mcp_resource_read_response(outgoing, request_id, result).await; + }); + Ok(()) + } + + async fn send_mcp_resource_read_response( + outgoing: Arc, + request_id: ConnectionRequestId, + result: anyhow::Result, + ) { + let result = result + .map_err(|error| internal_error(format!("{error:#}"))) + .and_then(|result| { + serde_json::from_value::(result).map_err(|error| { + internal_error(format!( + "failed to deserialize MCP resource read response: {error}" + )) + }) + }); + outgoing.send_result(request_id, result).await; + } + + async fn call_mcp_server_tool( + &self, + request_id: &ConnectionRequestId, + params: McpServerToolCallParams, + ) -> Result<(), JSONRPCErrorError> { + let outgoing = Arc::clone(&self.outgoing); + let thread_id = params.thread_id.clone(); + let (_, thread) = self.load_thread(&thread_id).await?; + let meta = with_mcp_tool_call_thread_id_meta(params.meta, &thread_id); + let request_id = request_id.clone(); + + tokio::spawn(async move { + let result = thread + .call_mcp_tool(¶ms.server, ¶ms.tool, params.arguments, meta) + .await + .map(McpServerToolCallResponse::from) + .map_err(|error| internal_error(format!("{error:#}"))); + outgoing.send_result(request_id, result).await; + }); + Ok(()) + } +} + +fn with_mcp_tool_call_thread_id_meta( + meta: Option, + thread_id: &str, +) -> Option { + match meta { + Some(serde_json::Value::Object(mut map)) => { + map.insert( + MCP_TOOL_THREAD_ID_META_KEY.to_string(), + serde_json::Value::String(thread_id.to_string()), + ); + Some(serde_json::Value::Object(map)) + } + None => { + let mut map = serde_json::Map::new(); + map.insert( + MCP_TOOL_THREAD_ID_META_KEY.to_string(), + serde_json::Value::String(thread_id.to_string()), + ); + Some(serde_json::Value::Object(map)) + } + other => other, + } +} diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs new file mode 100644 index 0000000000..61336aa713 --- /dev/null +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -0,0 +1,1720 @@ +use super::*; +use crate::error_code::internal_error; +use crate::error_code::invalid_request; +use codex_app_server_protocol::PluginAvailability; +use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginSharePrincipalRole; +use codex_app_server_protocol::PluginShareTargetRole; +use codex_config::types::McpServerConfig; +use codex_core_plugins::remote::is_valid_remote_plugin_id; +use codex_core_plugins::remote::validate_remote_plugin_id; +use codex_mcp::McpOAuthLoginSupport; +use codex_mcp::oauth_login_support; +use codex_mcp::should_retry_without_scopes; +use codex_rmcp_client::perform_oauth_login_silent; + +#[derive(Clone)] +pub(crate) struct PluginRequestProcessor { + auth_manager: Arc, + thread_manager: Arc, + outgoing: Arc, + analytics_events_client: AnalyticsEventsClient, + config_manager: ConfigManager, + workspace_settings_cache: Arc, +} + +fn plugin_skills_to_info( + skills: &[codex_core::skills::SkillMetadata], + disabled_skill_paths: &HashSet, +) -> Vec { + skills + .iter() + .map(|skill| SkillSummary { + name: skill.name.clone(), + description: skill.description.clone(), + short_description: skill.short_description.clone(), + interface: skill.interface.clone().map(|interface| { + codex_app_server_protocol::SkillInterface { + display_name: interface.display_name, + short_description: interface.short_description, + icon_small: interface.icon_small, + icon_large: interface.icon_large, + brand_color: interface.brand_color, + default_prompt: interface.default_prompt, + } + }), + path: Some(skill.path_to_skills_md.clone()), + enabled: !disabled_skill_paths.contains(&skill.path_to_skills_md), + }) + .collect() +} + +fn local_plugin_interface_to_info(interface: PluginManifestInterface) -> PluginInterface { + PluginInterface { + display_name: interface.display_name, + short_description: interface.short_description, + long_description: interface.long_description, + developer_name: interface.developer_name, + category: interface.category, + capabilities: interface.capabilities, + website_url: interface.website_url, + privacy_policy_url: interface.privacy_policy_url, + terms_of_service_url: interface.terms_of_service_url, + default_prompt: interface.default_prompt, + brand_color: interface.brand_color, + composer_icon: interface.composer_icon, + composer_icon_url: None, + logo: interface.logo, + logo_url: None, + screenshots: interface.screenshots, + screenshot_urls: Vec::new(), + } +} + +fn marketplace_plugin_source_to_info(source: MarketplacePluginSource) -> PluginSource { + match source { + MarketplacePluginSource::Local { path } => PluginSource::Local { path }, + MarketplacePluginSource::Git { + url, + path, + ref_name, + sha, + } => PluginSource::Git { + url, + path, + ref_name, + sha, + }, + } +} + +fn load_shared_plugin_ids_by_local_path( + config: &Config, +) -> Result, JSONRPCErrorError> { + codex_core_plugins::remote::load_plugin_share_remote_ids_by_local_path( + config.codex_home.as_path(), + ) + .map_err(|err| { + internal_error(format!( + "failed to load plugin share local path mapping: {err}" + )) + }) +} + +fn share_context_for_source( + source: &MarketplacePluginSource, + shared_plugin_ids_by_local_path: &std::collections::BTreeMap, +) -> Option { + match source { + MarketplacePluginSource::Local { path } => shared_plugin_ids_by_local_path + .get(path) + .cloned() + .map(|remote_plugin_id| PluginShareContext { + remote_plugin_id, + remote_version: None, + discoverability: None, + share_url: None, + creator_account_user_id: None, + creator_name: None, + share_principals: None, + }), + MarketplacePluginSource::Git { .. } => None, + } +} + +fn remote_plugin_share_discoverability( + discoverability: PluginShareDiscoverability, +) -> codex_core_plugins::remote::RemotePluginShareDiscoverability { + match discoverability { + PluginShareDiscoverability::Listed => { + codex_core_plugins::remote::RemotePluginShareDiscoverability::Listed + } + PluginShareDiscoverability::Unlisted => { + codex_core_plugins::remote::RemotePluginShareDiscoverability::Unlisted + } + PluginShareDiscoverability::Private => { + codex_core_plugins::remote::RemotePluginShareDiscoverability::Private + } + } +} + +fn remote_plugin_share_update_discoverability( + discoverability: PluginShareUpdateDiscoverability, +) -> codex_core_plugins::remote::RemotePluginShareUpdateDiscoverability { + match discoverability { + PluginShareUpdateDiscoverability::Unlisted => { + codex_core_plugins::remote::RemotePluginShareUpdateDiscoverability::Unlisted + } + PluginShareUpdateDiscoverability::Private => { + codex_core_plugins::remote::RemotePluginShareUpdateDiscoverability::Private + } + } +} + +fn validate_client_plugin_share_targets( + targets: &[PluginShareTarget], +) -> Result<(), JSONRPCErrorError> { + if targets + .iter() + .any(|target| target.principal_type == PluginSharePrincipalType::Workspace) + { + return Err(invalid_request( + "shareTargets cannot include workspace principals; use discoverability UNLISTED for workspace link access", + )); + } + Ok(()) +} + +fn remote_plugin_share_target_role( + role: PluginShareTargetRole, +) -> codex_core_plugins::remote::RemotePluginShareTargetRole { + match role { + PluginShareTargetRole::Reader => { + codex_core_plugins::remote::RemotePluginShareTargetRole::Reader + } + PluginShareTargetRole::Editor => { + codex_core_plugins::remote::RemotePluginShareTargetRole::Editor + } + } +} + +fn plugin_share_principal_role_from_remote( + role: codex_core_plugins::remote::RemotePluginSharePrincipalRole, +) -> PluginSharePrincipalRole { + match role { + codex_core_plugins::remote::RemotePluginSharePrincipalRole::Reader => { + PluginSharePrincipalRole::Reader + } + codex_core_plugins::remote::RemotePluginSharePrincipalRole::Editor => { + PluginSharePrincipalRole::Editor + } + codex_core_plugins::remote::RemotePluginSharePrincipalRole::Owner => { + PluginSharePrincipalRole::Owner + } + } +} + +fn remote_plugin_share_targets( + targets: Vec, +) -> Vec { + targets + .into_iter() + .map( + |target| codex_core_plugins::remote::RemotePluginShareTarget { + principal_type: match target.principal_type { + PluginSharePrincipalType::User => { + codex_core_plugins::remote::RemotePluginSharePrincipalType::User + } + PluginSharePrincipalType::Group => { + codex_core_plugins::remote::RemotePluginSharePrincipalType::Group + } + PluginSharePrincipalType::Workspace => { + codex_core_plugins::remote::RemotePluginSharePrincipalType::Workspace + } + }, + principal_id: target.principal_id, + role: remote_plugin_share_target_role(target.role), + }, + ) + .collect() +} + +fn plugin_share_principal_from_remote( + principal: codex_core_plugins::remote::RemotePluginSharePrincipal, +) -> PluginSharePrincipal { + PluginSharePrincipal { + principal_type: match principal.principal_type { + codex_core_plugins::remote::RemotePluginSharePrincipalType::User => { + PluginSharePrincipalType::User + } + codex_core_plugins::remote::RemotePluginSharePrincipalType::Group => { + PluginSharePrincipalType::Group + } + codex_core_plugins::remote::RemotePluginSharePrincipalType::Workspace => { + PluginSharePrincipalType::Workspace + } + }, + principal_id: principal.principal_id, + role: plugin_share_principal_role_from_remote(principal.role), + name: principal.name, + } +} + +impl PluginRequestProcessor { + pub(crate) fn new( + auth_manager: Arc, + thread_manager: Arc, + outgoing: Arc, + analytics_events_client: AnalyticsEventsClient, + config_manager: ConfigManager, + workspace_settings_cache: Arc, + ) -> Self { + Self { + auth_manager, + thread_manager, + outgoing, + analytics_events_client, + config_manager, + workspace_settings_cache, + } + } + + pub(crate) async fn plugin_list( + &self, + params: PluginListParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_list_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn plugin_read( + &self, + params: PluginReadParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_read_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn plugin_skill_read( + &self, + params: PluginSkillReadParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_skill_read_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn plugin_share_save( + &self, + params: PluginShareSaveParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_share_save_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn plugin_share_update_targets( + &self, + params: PluginShareUpdateTargetsParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_share_update_targets_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn plugin_share_list( + &self, + params: PluginShareListParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_share_list_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn plugin_share_delete( + &self, + params: PluginShareDeleteParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_share_delete_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn plugin_install( + &self, + params: PluginInstallParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_install_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn plugin_uninstall( + &self, + params: PluginUninstallParams, + ) -> Result, JSONRPCErrorError> { + self.plugin_uninstall_response(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) fn effective_plugins_changed_callback(&self) -> Arc { + let thread_manager = Arc::clone(&self.thread_manager); + let config_manager = self.config_manager.clone(); + Arc::new(move || { + Self::spawn_effective_plugins_changed_task( + Arc::clone(&thread_manager), + config_manager.clone(), + ); + }) + } + + fn on_effective_plugins_changed(&self) { + Self::spawn_effective_plugins_changed_task( + Arc::clone(&self.thread_manager), + self.config_manager.clone(), + ); + } + + fn spawn_effective_plugins_changed_task( + thread_manager: Arc, + config_manager: ConfigManager, + ) { + tokio::spawn(async move { + thread_manager.plugins_manager().clear_cache(); + thread_manager.skills_manager().clear_cache(); + if thread_manager.list_thread_ids().await.is_empty() { + return; + } + crate::mcp_refresh::queue_best_effort_refresh(&thread_manager, &config_manager).await; + }); + } + + fn clear_plugin_related_caches(&self) { + self.thread_manager.plugins_manager().clear_cache(); + self.thread_manager.skills_manager().clear_cache(); + } + + async fn load_latest_config( + &self, + fallback_cwd: Option, + ) -> Result { + self.config_manager + .load_latest_config(fallback_cwd) + .await + .map_err(|err| internal_error(format!("failed to reload config: {err}"))) + } + + async fn workspace_codex_plugins_enabled( + &self, + config: &Config, + auth: Option<&CodexAuth>, + ) -> bool { + match workspace_settings::codex_plugins_enabled_for_workspace( + config, + auth, + Some(&self.workspace_settings_cache), + ) + .await + { + Ok(enabled) => enabled, + Err(err) => { + warn!( + "failed to fetch workspace Codex plugins setting; allowing Codex plugins: {err:#}" + ); + true + } + } + } + + async fn plugin_list_response( + &self, + params: PluginListParams, + ) -> Result { + let plugins_manager = self.thread_manager.plugins_manager(); + let PluginListParams { + cwds, + marketplace_kinds, + } = params; + let roots = cwds.unwrap_or_default(); + let explicit_marketplace_kinds = marketplace_kinds.is_some(); + let marketplace_kinds = + marketplace_kinds.unwrap_or_else(|| vec![PluginListMarketplaceKind::Local]); + let include_local = marketplace_kinds.contains(&PluginListMarketplaceKind::Local); + + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + let empty_response = || PluginListResponse { + marketplaces: Vec::new(), + marketplace_load_errors: Vec::new(), + featured_plugin_ids: Vec::new(), + }; + if !config.features.enabled(Feature::Plugins) { + return Ok(empty_response()); + } + let auth = self.auth_manager.auth().await; + if !self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await + { + return Ok(empty_response()); + } + let plugins_input = config.plugins_config_input(); + let (mut data, marketplace_load_errors) = if include_local { + plugins_manager.maybe_start_plugin_list_background_tasks_for_config( + &plugins_input, + auth.clone(), + &roots, + Some(self.effective_plugins_changed_callback()), + ); + + let config_for_marketplace_listing = plugins_input.clone(); + let plugins_manager_for_marketplace_listing = plugins_manager.clone(); + let shared_plugin_ids_by_local_path = load_shared_plugin_ids_by_local_path(&config)?; + match tokio::task::spawn_blocking(move || { + let outcome = plugins_manager_for_marketplace_listing + .list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?; + Ok::< + ( + Vec, + Vec, + ), + MarketplaceError, + >(( + outcome + .marketplaces + .into_iter() + .map(|marketplace| PluginMarketplaceEntry { + name: marketplace.name, + path: Some(marketplace.path), + interface: marketplace.interface.map(|interface| { + MarketplaceInterface { + display_name: interface.display_name, + } + }), + plugins: marketplace + .plugins + .into_iter() + .map(|plugin| { + let share_context = share_context_for_source( + &plugin.source, + &shared_plugin_ids_by_local_path, + ); + PluginSummary { + id: plugin.id, + remote_plugin_id: None, + local_version: plugin.local_version, + installed: plugin.installed, + enabled: plugin.enabled, + name: plugin.name, + share_context, + source: marketplace_plugin_source_to_info(plugin.source), + install_policy: plugin.policy.installation.into(), + auth_policy: plugin.policy.authentication.into(), + availability: PluginAvailability::Available, + interface: plugin + .interface + .map(local_plugin_interface_to_info), + keywords: plugin.keywords, + } + }) + .collect(), + }) + .collect(), + outcome + .errors + .into_iter() + .map(|err| codex_app_server_protocol::MarketplaceLoadErrorInfo { + marketplace_path: err.path, + message: err.message, + }) + .collect(), + )) + }) + .await + { + Ok(Ok(outcome)) => outcome, + Ok(Err(err)) => { + return Err(Self::marketplace_error(err, "list marketplace plugins")); + } + Err(err) => { + return Err(internal_error(format!( + "failed to list marketplace plugins: {err}" + ))); + } + } + } else { + (Vec::new(), Vec::new()) + }; + + let mut remote_sources = Vec::new(); + if !explicit_marketplace_kinds && config.features.enabled(Feature::RemotePlugin) { + remote_sources.push(RemoteMarketplaceSource::Global); + } + if marketplace_kinds.contains(&PluginListMarketplaceKind::WorkspaceDirectory) { + remote_sources.push(RemoteMarketplaceSource::WorkspaceDirectory); + } + if marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe) + && config.features.enabled(Feature::PluginSharing) + { + remote_sources.push(RemoteMarketplaceSource::SharedWithMe); + } + if !remote_sources.is_empty() { + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + match codex_core_plugins::remote::fetch_remote_marketplaces( + &remote_plugin_service_config, + auth.as_ref(), + &remote_sources, + ) + .await + { + Ok(remote_marketplaces) => { + for remote_marketplace in remote_marketplaces + .into_iter() + .map(remote_marketplace_to_info) + { + if let Some(existing) = data + .iter_mut() + .find(|marketplace| marketplace.name == remote_marketplace.name) + { + *existing = remote_marketplace; + } else { + data.push(remote_marketplace); + } + } + } + Err( + err @ (RemotePluginCatalogError::AuthRequired + | RemotePluginCatalogError::UnsupportedAuthMode), + ) if explicit_marketplace_kinds => { + return Err(remote_plugin_catalog_error_to_jsonrpc( + err, + "list remote plugin catalog", + )); + } + Err( + RemotePluginCatalogError::AuthRequired + | RemotePluginCatalogError::UnsupportedAuthMode, + ) => {} + Err(err) if explicit_marketplace_kinds => { + return Err(remote_plugin_catalog_error_to_jsonrpc( + err, + "list remote plugin catalog", + )); + } + Err(err) => { + warn!( + error = %err, + "plugin/list remote plugin catalog fetch failed; returning local marketplaces only" + ); + } + } + } + + let featured_plugin_ids = if data + .iter() + .any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) + { + match plugins_manager + .featured_plugin_ids_for_config(&plugins_input, auth.as_ref()) + .await + { + Ok(featured_plugin_ids) => featured_plugin_ids, + Err(err) => { + warn!( + error = %err, + "plugin/list featured plugin fetch failed; returning empty featured ids" + ); + Vec::new() + } + } + } else { + Vec::new() + }; + + Ok(PluginListResponse { + marketplaces: data, + marketplace_load_errors, + featured_plugin_ids, + }) + } + + async fn plugin_read_response( + &self, + params: PluginReadParams, + ) -> Result { + let plugins_manager = self.thread_manager.plugins_manager(); + let PluginReadParams { + marketplace_path, + remote_marketplace_name, + plugin_name, + } = params; + let read_source = match (marketplace_path, remote_marketplace_name) { + (Some(marketplace_path), None) => Ok(marketplace_path), + (None, Some(remote_marketplace_name)) => Err(remote_marketplace_name), + (Some(_), Some(_)) | (None, None) => { + return Err(invalid_request( + "plugin/read requires exactly one of marketplacePath or remoteMarketplaceName", + )); + } + }; + let config_cwd = read_source.as_ref().ok().and_then(|marketplace_path| { + marketplace_path.as_path().parent().map(Path::to_path_buf) + }); + + let config = self.load_latest_config(config_cwd).await?; + let plugins_input = config.plugins_config_input(); + + let plugin = match read_source { + Ok(marketplace_path) => { + let request = PluginReadRequest { + plugin_name, + marketplace_path, + }; + let outcome = plugins_manager + .read_plugin_for_config(&plugins_input, &request) + .await + .map_err(|err| Self::marketplace_error(err, "read plugin details"))?; + let shared_plugin_ids_by_local_path = + load_shared_plugin_ids_by_local_path(&config)?; + let share_context = share_context_for_source( + &outcome.plugin.source, + &shared_plugin_ids_by_local_path, + ); + let share_context = match share_context { + Some(context) => { + let auth = self.auth_manager.auth().await; + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + match codex_core_plugins::remote::fetch_remote_plugin_share_context( + &remote_plugin_service_config, + auth.as_ref(), + &context.remote_plugin_id, + ) + .await + { + Ok(Some(remote_share_context)) => { + if remote_share_context.share_principals.is_some() { + Some(remote_plugin_share_context_to_info(remote_share_context)) + } else { + let remote_version = remote_share_context.remote_version; + let remote_plugin_id = context.remote_plugin_id.clone(); + warn!( + remote_plugin_id = %remote_plugin_id, + "remote shared plugin detail did not include share principals; returning local share mapping context with remote version" + ); + Some(PluginShareContext { + remote_version, + ..context + }) + } + } + Ok(None) => { + warn!( + remote_plugin_id = %context.remote_plugin_id, + "remote shared plugin detail did not include share context; returning local share mapping context" + ); + Some(context) + } + Err(err) => { + warn!( + remote_plugin_id = %context.remote_plugin_id, + error = %err, + "failed to hydrate local plugin share context; returning local share mapping context" + ); + Some(context) + } + } + } + None => None, + }; + let environment_manager = self.thread_manager.environment_manager(); + let app_summaries = + load_plugin_app_summaries(&config, &outcome.plugin.apps, &environment_manager) + .await; + let visible_skills = outcome + .plugin + .skills + .iter() + .filter(|skill| { + skill.matches_product_restriction_for_product( + self.thread_manager.session_source().restriction_product(), + ) + }) + .cloned() + .collect::>(); + PluginDetail { + marketplace_name: outcome.marketplace_name, + marketplace_path: outcome.marketplace_path, + summary: PluginSummary { + id: outcome.plugin.id, + remote_plugin_id: None, + local_version: outcome.plugin.local_version, + name: outcome.plugin.name, + share_context, + source: marketplace_plugin_source_to_info(outcome.plugin.source), + installed: outcome.plugin.installed, + enabled: outcome.plugin.enabled, + install_policy: outcome.plugin.policy.installation.into(), + auth_policy: outcome.plugin.policy.authentication.into(), + availability: PluginAvailability::Available, + interface: outcome.plugin.interface.map(local_plugin_interface_to_info), + keywords: outcome.plugin.keywords, + }, + description: outcome.plugin.description, + skills: plugin_skills_to_info( + &visible_skills, + &outcome.plugin.disabled_skill_paths, + ), + hooks: outcome + .plugin + .hooks + .into_iter() + .map(|hook| codex_app_server_protocol::PluginHookSummary { + key: hook.key, + event_name: hook.event_name.into(), + }) + .collect(), + apps: app_summaries, + mcp_servers: outcome.plugin.mcp_server_names, + } + } + Err(remote_marketplace_name) => { + if !config.features.enabled(Feature::Plugins) { + return Err(invalid_request(format!( + "remote plugin read is not enabled for marketplace {remote_marketplace_name}" + ))); + } + let auth = self.auth_manager.auth().await; + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + validate_remote_plugin_id(&plugin_name)?; + let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail( + &remote_plugin_service_config, + auth.as_ref(), + &remote_marketplace_name, + &plugin_name, + ) + .await + .map_err(|err| { + remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin details") + })?; + let plugin_apps = remote_detail + .app_ids + .iter() + .cloned() + .map(codex_plugin::AppConnectorId) + .collect::>(); + let environment_manager = self.thread_manager.environment_manager(); + let app_summaries = + load_plugin_app_summaries(&config, &plugin_apps, &environment_manager).await; + remote_plugin_detail_to_info(remote_detail, app_summaries) + } + }; + + Ok(PluginReadResponse { plugin }) + } + + async fn plugin_skill_read_response( + &self, + params: PluginSkillReadParams, + ) -> Result { + let PluginSkillReadParams { + remote_marketplace_name, + remote_plugin_id, + skill_name, + } = params; + + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + if !config.features.enabled(Feature::Plugins) { + return Err(invalid_request(format!( + "remote plugin skill read is not enabled for marketplace {remote_marketplace_name}" + ))); + } + validate_remote_plugin_id(&remote_plugin_id)?; + if skill_name.is_empty() { + return Err(invalid_request( + "invalid remote plugin skill name: cannot be empty", + )); + } + + let auth = self.auth_manager.auth().await; + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + let remote_skill_detail = codex_core_plugins::remote::fetch_remote_plugin_skill_detail( + &remote_plugin_service_config, + auth.as_ref(), + &remote_marketplace_name, + &remote_plugin_id, + &skill_name, + ) + .await + .map_err(|err| { + remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin skill details") + })?; + + Ok(PluginSkillReadResponse { + contents: remote_skill_detail.contents, + }) + } + + async fn plugin_share_save_response( + &self, + params: PluginShareSaveParams, + ) -> Result { + let (config, auth) = self.load_plugin_share_config_and_auth().await?; + if !config.features.enabled(Feature::PluginSharing) { + return Err(invalid_request("plugin sharing is disabled")); + } + let PluginShareSaveParams { + plugin_path, + remote_plugin_id, + discoverability, + share_targets, + } = params; + if let Some(remote_plugin_id) = remote_plugin_id.as_ref() + && (remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(remote_plugin_id)) + { + return Err(invalid_request("invalid remote plugin id")); + } + if remote_plugin_id.is_some() && (discoverability.is_some() || share_targets.is_some()) { + return Err(invalid_request( + "discoverability and shareTargets are only supported when creating a plugin share; use plugin/share/updateTargets to update share settings", + )); + } + if discoverability == Some(PluginShareDiscoverability::Listed) { + return Err(invalid_request( + "discoverability LISTED is not supported for plugin/share/save; use UNLISTED or PRIVATE", + )); + } + if let Some(share_targets) = share_targets.as_ref() { + validate_client_plugin_share_targets(share_targets)?; + } + + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + let access_policy = codex_core_plugins::remote::RemotePluginShareAccessPolicy { + discoverability: discoverability.map(remote_plugin_share_discoverability), + share_targets: share_targets.map(remote_plugin_share_targets), + }; + let result = codex_core_plugins::remote::save_remote_plugin_share( + &remote_plugin_service_config, + auth.as_ref(), + config.codex_home.as_path(), + &plugin_path, + remote_plugin_id.as_deref(), + access_policy, + ) + .await + .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "save remote plugin share"))?; + let remote_plugin_id = result.remote_plugin_id; + self.clear_plugin_related_caches(); + Ok(PluginShareSaveResponse { + remote_plugin_id, + share_url: result.share_url.unwrap_or_default(), + }) + } + + async fn plugin_share_update_targets_response( + &self, + params: PluginShareUpdateTargetsParams, + ) -> Result { + let (config, auth) = self.load_plugin_share_config_and_auth().await?; + let PluginShareUpdateTargetsParams { + remote_plugin_id, + discoverability, + share_targets, + } = params; + if remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(&remote_plugin_id) { + return Err(invalid_request("invalid remote plugin id")); + } + validate_client_plugin_share_targets(&share_targets)?; + + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + let result = codex_core_plugins::remote::update_remote_plugin_share_targets( + &remote_plugin_service_config, + auth.as_ref(), + &remote_plugin_id, + remote_plugin_share_targets(share_targets), + remote_plugin_share_update_discoverability(discoverability), + ) + .await + .map_err(|err| { + remote_plugin_catalog_error_to_jsonrpc(err, "update remote plugin share targets") + })?; + self.clear_plugin_related_caches(); + Ok(PluginShareUpdateTargetsResponse { + principals: result + .principals + .into_iter() + .map(plugin_share_principal_from_remote) + .collect(), + discoverability: remote_plugin_share_discoverability_to_info(result.discoverability), + }) + } + + async fn plugin_share_list_response( + &self, + _params: PluginShareListParams, + ) -> Result { + let (config, auth) = self.load_plugin_share_config_and_auth().await?; + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + let data = codex_core_plugins::remote::list_remote_plugin_shares( + &remote_plugin_service_config, + auth.as_ref(), + config.codex_home.as_path(), + ) + .await + .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "list remote plugin shares"))? + .into_iter() + .map(|summary| { + let RemoteCatalogPluginShareSummary { + summary, + local_plugin_path, + } = summary; + let plugin = remote_plugin_summary_to_info(summary); + PluginShareListItem { + plugin, + local_plugin_path, + } + }) + .collect(); + Ok(PluginShareListResponse { data }) + } + + async fn plugin_share_delete_response( + &self, + params: PluginShareDeleteParams, + ) -> Result { + let (config, auth) = self.load_plugin_share_config_and_auth().await?; + let PluginShareDeleteParams { remote_plugin_id } = params; + if remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(&remote_plugin_id) { + return Err(invalid_request("invalid remote plugin id")); + } + + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + codex_core_plugins::remote::delete_remote_plugin_share( + &remote_plugin_service_config, + auth.as_ref(), + config.codex_home.as_path(), + &remote_plugin_id, + ) + .await + .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "delete remote plugin share"))?; + self.clear_plugin_related_caches(); + Ok(PluginShareDeleteResponse {}) + } + + async fn load_plugin_share_config_and_auth( + &self, + ) -> Result<(Config, Option), JSONRPCErrorError> { + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + if !config.features.enabled(Feature::Plugins) { + return Err(invalid_request("plugin sharing is not enabled")); + } + let auth = self.auth_manager.auth().await; + Ok((config, auth)) + } + + async fn plugin_install_response( + &self, + params: PluginInstallParams, + ) -> Result { + let PluginInstallParams { + marketplace_path, + remote_marketplace_name, + plugin_name, + } = params; + let marketplace_path = match (marketplace_path, remote_marketplace_name) { + (Some(marketplace_path), None) => marketplace_path, + (None, Some(remote_marketplace_name)) => { + return self + .remote_plugin_install_response(remote_marketplace_name, plugin_name) + .await; + } + (Some(_), Some(_)) | (None, None) => { + return Err(invalid_request( + "plugin/install requires exactly one of marketplacePath or remoteMarketplaceName", + )); + } + }; + let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf); + let config = self.load_latest_config(config_cwd.clone()).await?; + let auth = self.auth_manager.auth().await; + + if !self + .workspace_codex_plugins_enabled(&config, auth.as_ref()) + .await + { + return Err(invalid_request( + "Codex plugins are disabled for this workspace", + )); + } + + let plugins_manager = self.thread_manager.plugins_manager(); + let request = PluginInstallRequest { + plugin_name, + marketplace_path, + }; + + let result = plugins_manager + .install_plugin(request) + .await + .map_err(Self::plugin_install_error)?; + let config = match self.load_latest_config(config_cwd).await { + Ok(config) => config, + Err(err) => { + warn!( + "failed to reload config after plugin install, using current config: {err:?}" + ); + config + } + }; + + self.on_effective_plugins_changed(); + + let plugin_mcp_servers = load_plugin_mcp_servers(result.installed_path.as_path()).await; + if !plugin_mcp_servers.is_empty() { + self.start_plugin_mcp_oauth_logins(&config, plugin_mcp_servers) + .await; + } + + let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await; + let auth = self.auth_manager.auth().await; + let apps_needing_auth = self + .plugin_apps_needing_auth_for_install( + &config, + auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth), + &result.plugin_id.as_key(), + &plugin_apps, + ) + .await; + + Ok(PluginInstallResponse { + auth_policy: result.auth_policy.into(), + apps_needing_auth, + }) + } + + async fn remote_plugin_install_response( + &self, + remote_marketplace_name: String, + remote_plugin_id: String, + ) -> Result { + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + if !config.features.enabled(Feature::Plugins) { + return Err(invalid_request(format!( + "remote plugin install is not enabled for marketplace {remote_marketplace_name}" + ))); + } + validate_remote_plugin_id(&remote_plugin_id)?; + + let auth = self.auth_manager.auth().await; + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + let remote_detail = + codex_core_plugins::remote::fetch_remote_plugin_detail_with_download_urls( + &remote_plugin_service_config, + auth.as_ref(), + &remote_marketplace_name, + &remote_plugin_id, + ) + .await + .map_err(|err| { + remote_plugin_catalog_error_to_jsonrpc( + err, + "read remote plugin details before install", + ) + })?; + if remote_detail.summary.availability == PluginAvailability::DisabledByAdmin { + return Err(invalid_request(format!( + "remote plugin {remote_plugin_id} is disabled by admin" + ))); + } + if remote_detail.summary.install_policy == PluginInstallPolicy::NotAvailable { + return Err(invalid_request(format!( + "remote plugin {remote_plugin_id} is not available for install" + ))); + } + let actual_remote_marketplace_name = remote_detail.marketplace_name.clone(); + // Direct install writes the same cache tree that installed-plugin sync + // prunes before the backend installed snapshot can include this plugin. + let _remote_plugin_cache_mutation = + codex_core_plugins::remote::mark_remote_plugin_cache_mutation_in_flight( + config.codex_home.as_path(), + &actual_remote_marketplace_name, + &remote_detail.summary.name, + ); + let validated_bundle = codex_core_plugins::remote_bundle::validate_remote_plugin_bundle( + &remote_plugin_id, + &actual_remote_marketplace_name, + &remote_detail.summary.name, + remote_detail.release_version.as_deref(), + remote_detail.bundle_download_url.as_deref(), + ) + .map_err(remote_plugin_bundle_install_error_to_jsonrpc)?; + + let result = codex_core_plugins::remote_bundle::download_and_install_remote_plugin_bundle( + config.codex_home.to_path_buf(), + validated_bundle, + ) + .await + .map_err(remote_plugin_bundle_install_error_to_jsonrpc)?; + + // Cache first so a backend install cannot succeed when local materialization fails. + // If this backend call fails, the cache entry is harmless because remote installed state + // is still backend-gated. + codex_core_plugins::remote::install_remote_plugin( + &remote_plugin_service_config, + auth.as_ref(), + &actual_remote_marketplace_name, + &remote_plugin_id, + ) + .await + .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin"))?; + + self.thread_manager + .plugins_manager() + .maybe_start_remote_installed_plugins_cache_refresh_after_mutation( + &config.plugins_config_input(), + auth.clone(), + Some(self.effective_plugins_changed_callback()), + ); + + let mut plugin_metadata = + plugin_telemetry_metadata_from_root(&result.plugin_id, &result.installed_path).await; + plugin_metadata.remote_plugin_id = Some(remote_plugin_id); + self.analytics_events_client + .track_plugin_installed(plugin_metadata); + + let plugin_mcp_servers = load_plugin_mcp_servers(result.installed_path.as_path()).await; + if !plugin_mcp_servers.is_empty() { + self.start_plugin_mcp_oauth_logins(&config, plugin_mcp_servers) + .await; + } + + let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await; + let apps_needing_auth = self + .plugin_apps_needing_auth_for_install( + &config, + auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth), + &result.plugin_id.as_key(), + &plugin_apps, + ) + .await; + + Ok(PluginInstallResponse { + auth_policy: remote_detail.summary.auth_policy, + apps_needing_auth, + }) + } + + async fn plugin_apps_needing_auth_for_install( + &self, + config: &Config, + is_chatgpt_auth: bool, + plugin_id: &str, + plugin_apps: &[codex_plugin::AppConnectorId], + ) -> Vec { + if plugin_apps.is_empty() || !config.features.apps_enabled_for_auth(is_chatgpt_auth) { + return Vec::new(); + } + + let environment_manager = self.thread_manager.environment_manager(); + let (all_connectors_result, accessible_connectors_result) = tokio::join!( + connectors::list_all_connectors_with_options(config, /*force_refetch*/ true), + connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( + config, + /*force_refetch*/ true, + &environment_manager + ), + ); + + let all_connectors = match all_connectors_result { + Ok(connectors) => connectors, + Err(err) => { + warn!( + plugin = plugin_id, + "failed to load app metadata after plugin install: {err:#}" + ); + connectors::list_cached_all_connectors(config) + .await + .unwrap_or_default() + } + }; + let all_connectors = connectors::connectors_for_plugin_apps(all_connectors, plugin_apps); + let (accessible_connectors, codex_apps_ready) = match accessible_connectors_result { + Ok(status) => (status.connectors, status.codex_apps_ready), + Err(err) => { + warn!( + plugin = plugin_id, + "failed to load accessible apps after plugin install: {err:#}" + ); + ( + connectors::list_cached_accessible_connectors_from_mcp_tools(config) + .await + .unwrap_or_default(), + false, + ) + } + }; + if !codex_apps_ready { + warn!( + plugin = plugin_id, + "codex_apps MCP not ready after plugin install; skipping appsNeedingAuth check" + ); + } + + plugin_apps_needing_auth( + &all_connectors, + &accessible_connectors, + plugin_apps, + codex_apps_ready, + ) + } + + async fn start_plugin_mcp_oauth_logins( + &self, + config: &Config, + plugin_mcp_servers: HashMap, + ) { + for (name, server) in plugin_mcp_servers { + let oauth_config = match oauth_login_support(&server.transport).await { + McpOAuthLoginSupport::Supported(config) => config, + McpOAuthLoginSupport::Unsupported => continue, + McpOAuthLoginSupport::Unknown(err) => { + warn!( + "MCP server may or may not require login for plugin install {name}: {err}" + ); + continue; + } + }; + + let resolved_scopes = resolve_oauth_scopes( + /*explicit_scopes*/ None, + server.scopes.clone(), + oauth_config.discovered_scopes.clone(), + ); + + let store_mode = config.mcp_oauth_credentials_store_mode; + let callback_port = config.mcp_oauth_callback_port; + let callback_url = config.mcp_oauth_callback_url.clone(); + let outgoing = Arc::clone(&self.outgoing); + let notification_name = name.clone(); + + tokio::spawn(async move { + let first_attempt = perform_oauth_login_silent( + &name, + &oauth_config.url, + store_mode, + oauth_config.http_headers.clone(), + oauth_config.env_http_headers.clone(), + &resolved_scopes.scopes, + server.oauth_resource.as_deref(), + callback_port, + callback_url.as_deref(), + ) + .await; + + let final_result = match first_attempt { + Err(err) if should_retry_without_scopes(&resolved_scopes, &err) => { + perform_oauth_login_silent( + &name, + &oauth_config.url, + store_mode, + oauth_config.http_headers, + oauth_config.env_http_headers, + &[], + server.oauth_resource.as_deref(), + callback_port, + callback_url.as_deref(), + ) + .await + } + result => result, + }; + + let (success, error) = match final_result { + Ok(()) => (true, None), + Err(err) => (false, Some(err.to_string())), + }; + + let notification = ServerNotification::McpServerOauthLoginCompleted( + McpServerOauthLoginCompletedNotification { + name: notification_name, + success, + error, + }, + ); + outgoing.send_server_notification(notification).await; + }); + } + } + + async fn plugin_uninstall_response( + &self, + params: PluginUninstallParams, + ) -> Result { + let PluginUninstallParams { plugin_id } = params; + if codex_plugin::PluginId::parse(&plugin_id).is_err() + && !is_valid_remote_plugin_id(&plugin_id) + { + return Err(invalid_request("invalid remote plugin id")); + } + if is_valid_remote_plugin_id(&plugin_id) { + return self.remote_plugin_uninstall_response(plugin_id).await; + } + let plugins_manager = self.thread_manager.plugins_manager(); + + plugins_manager + .uninstall_plugin(plugin_id) + .await + .map_err(Self::plugin_uninstall_error)?; + match self.load_latest_config(/*fallback_cwd*/ None).await { + Ok(_) => self.on_effective_plugins_changed(), + Err(err) => { + warn!( + "failed to reload config after plugin uninstall, clearing plugin-related caches only: {err:?}" + ); + self.clear_plugin_related_caches(); + } + } + Ok(PluginUninstallResponse {}) + } + + fn plugin_install_error(err: CorePluginInstallError) -> JSONRPCErrorError { + if err.is_invalid_request() { + return invalid_request(err.to_string()); + } + + match err { + CorePluginInstallError::Marketplace(err) => { + Self::marketplace_error(err, "install plugin") + } + CorePluginInstallError::Config(err) => { + internal_error(format!("failed to persist installed plugin config: {err}")) + } + CorePluginInstallError::Remote(err) => { + internal_error(format!("failed to enable remote plugin: {err}")) + } + CorePluginInstallError::Join(err) => { + internal_error(format!("failed to install plugin: {err}")) + } + CorePluginInstallError::Store(err) => { + internal_error(format!("failed to install plugin: {err}")) + } + } + } + + fn plugin_uninstall_error(err: CorePluginUninstallError) -> JSONRPCErrorError { + if err.is_invalid_request() { + return invalid_request(err.to_string()); + } + + match err { + CorePluginUninstallError::Config(err) => { + internal_error(format!("failed to clear plugin config: {err}")) + } + CorePluginUninstallError::Remote(err) => { + internal_error(format!("failed to uninstall remote plugin: {err}")) + } + CorePluginUninstallError::Join(err) => { + internal_error(format!("failed to uninstall plugin: {err}")) + } + CorePluginUninstallError::Store(err) => { + internal_error(format!("failed to uninstall plugin: {err}")) + } + CorePluginUninstallError::InvalidPluginId(_) => { + unreachable!("invalid plugin ids are handled above"); + } + } + } + + fn marketplace_error(err: MarketplaceError, action: &str) -> JSONRPCErrorError { + match err { + MarketplaceError::MarketplaceNotFound { .. } + | MarketplaceError::InvalidMarketplaceFile { .. } + | MarketplaceError::PluginNotFound { .. } + | MarketplaceError::PluginNotAvailable { .. } + | MarketplaceError::PluginsDisabled + | MarketplaceError::InvalidPlugin(_) => invalid_request(err.to_string()), + MarketplaceError::Io { .. } => internal_error(format!("failed to {action}: {err}")), + } + } + + async fn remote_plugin_uninstall_response( + &self, + plugin_id: String, + ) -> Result { + let config = self.load_latest_config(/*fallback_cwd*/ None).await?; + if !config.features.enabled(Feature::Plugins) { + return Err(invalid_request("remote plugin uninstall is not enabled")); + } + validate_remote_plugin_id(&plugin_id)?; + + let auth = self.auth_manager.auth().await; + let remote_plugin_service_config = RemotePluginServiceConfig { + chatgpt_base_url: config.chatgpt_base_url.clone(), + }; + let uninstall_result = codex_core_plugins::remote::uninstall_remote_plugin( + &remote_plugin_service_config, + auth.as_ref(), + config.codex_home.to_path_buf(), + &plugin_id, + ) + .await; + + if matches!( + &uninstall_result, + Ok(()) | Err(RemotePluginCatalogError::CacheRemove(_)) + ) { + let plugins_manager = self.thread_manager.plugins_manager(); + if plugins_manager.clear_remote_installed_plugins_cache() { + self.on_effective_plugins_changed(); + } + plugins_manager.maybe_start_remote_installed_plugins_cache_refresh_after_mutation( + &config.plugins_config_input(), + auth.clone(), + Some(self.effective_plugins_changed_callback()), + ); + } + + uninstall_result.map_err(|err| { + remote_plugin_catalog_error_to_jsonrpc(err, "uninstall remote plugin") + })?; + Ok(PluginUninstallResponse {}) + } +} + +async fn load_plugin_app_summaries( + config: &Config, + plugin_apps: &[codex_plugin::AppConnectorId], + environment_manager: &EnvironmentManager, +) -> Vec { + if plugin_apps.is_empty() { + return Vec::new(); + } + + let connectors = + match connectors::list_all_connectors_with_options(config, /*force_refetch*/ false).await { + Ok(connectors) => connectors, + Err(err) => { + warn!("failed to load app metadata for plugin/read: {err:#}"); + connectors::list_cached_all_connectors(config) + .await + .unwrap_or_default() + } + }; + + let plugin_connectors = connectors::connectors_for_plugin_apps(connectors, plugin_apps); + + let accessible_connectors = + match connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( + config, + /*force_refetch*/ false, + environment_manager, + ) + .await + { + Ok(status) if status.codex_apps_ready => status.connectors, + Ok(_) => { + return plugin_connectors + .into_iter() + .map(AppSummary::from) + .collect(); + } + Err(err) => { + warn!("failed to load app auth state for plugin/read: {err:#}"); + return plugin_connectors + .into_iter() + .map(AppSummary::from) + .collect(); + } + }; + + let accessible_ids = accessible_connectors + .iter() + .map(|connector| connector.id.as_str()) + .collect::>(); + + plugin_connectors + .into_iter() + .map(|connector| { + let needs_auth = !accessible_ids.contains(connector.id.as_str()); + AppSummary { + id: connector.id, + name: connector.name, + description: connector.description, + install_url: connector.install_url, + needs_auth, + } + }) + .collect() +} + +fn plugin_apps_needing_auth( + all_connectors: &[AppInfo], + accessible_connectors: &[AppInfo], + plugin_apps: &[codex_plugin::AppConnectorId], + codex_apps_ready: bool, +) -> Vec { + if !codex_apps_ready { + return Vec::new(); + } + + let accessible_ids = accessible_connectors + .iter() + .map(|connector| connector.id.as_str()) + .collect::>(); + let plugin_app_ids = plugin_apps + .iter() + .map(|connector_id| connector_id.0.as_str()) + .collect::>(); + + all_connectors + .iter() + .filter(|connector| { + plugin_app_ids.contains(connector.id.as_str()) + && !accessible_ids.contains(connector.id.as_str()) + }) + .cloned() + .map(|connector| AppSummary { + id: connector.id, + name: connector.name, + description: connector.description, + install_url: connector.install_url, + needs_auth: true, + }) + .collect() +} + +fn remote_marketplace_to_info(marketplace: RemoteMarketplace) -> PluginMarketplaceEntry { + PluginMarketplaceEntry { + name: marketplace.name, + path: None, + interface: Some(MarketplaceInterface { + display_name: Some(marketplace.display_name), + }), + plugins: marketplace + .plugins + .into_iter() + .map(remote_plugin_summary_to_info) + .collect(), + } +} + +fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginSummary { + PluginSummary { + id: summary.id, + remote_plugin_id: Some(summary.remote_plugin_id), + local_version: None, + name: summary.name, + share_context: summary + .share_context + .map(remote_plugin_share_context_to_info), + source: PluginSource::Remote, + installed: summary.installed, + enabled: summary.enabled, + install_policy: summary.install_policy, + auth_policy: summary.auth_policy, + availability: summary.availability, + interface: summary.interface, + keywords: summary.keywords, + } +} + +fn remote_plugin_share_context_to_info( + context: RemoteCatalogPluginShareContext, +) -> PluginShareContext { + PluginShareContext { + remote_plugin_id: context.remote_plugin_id, + remote_version: context.remote_version, + discoverability: Some(remote_plugin_share_discoverability_to_info( + context.discoverability, + )), + share_url: context.share_url, + creator_account_user_id: context.creator_account_user_id, + creator_name: context.creator_name, + share_principals: context.share_principals.map(|principals| { + principals + .into_iter() + .map(plugin_share_principal_from_remote) + .collect() + }), + } +} + +fn remote_plugin_share_discoverability_to_info( + discoverability: codex_core_plugins::remote::RemotePluginShareDiscoverability, +) -> PluginShareDiscoverability { + match discoverability { + codex_core_plugins::remote::RemotePluginShareDiscoverability::Listed => { + PluginShareDiscoverability::Listed + } + codex_core_plugins::remote::RemotePluginShareDiscoverability::Unlisted => { + PluginShareDiscoverability::Unlisted + } + codex_core_plugins::remote::RemotePluginShareDiscoverability::Private => { + PluginShareDiscoverability::Private + } + } +} + +fn remote_plugin_detail_to_info( + detail: RemoteCatalogPluginDetail, + apps: Vec, +) -> PluginDetail { + PluginDetail { + marketplace_name: detail.marketplace_name, + marketplace_path: None, + summary: remote_plugin_summary_to_info(detail.summary), + description: detail.description, + skills: detail + .skills + .into_iter() + .map(|skill| SkillSummary { + name: skill.name, + description: skill.description, + short_description: skill.short_description, + interface: skill.interface, + path: None, + enabled: skill.enabled, + }) + .collect(), + hooks: Vec::new(), + apps, + mcp_servers: Vec::new(), + } +} + +fn remote_plugin_catalog_error_to_jsonrpc( + err: RemotePluginCatalogError, + context: &str, +) -> JSONRPCErrorError { + let message = format!("{context}: {err}"); + match &err { + RemotePluginCatalogError::AuthRequired | RemotePluginCatalogError::UnsupportedAuthMode => { + invalid_request(message) + } + RemotePluginCatalogError::UnexpectedStatus { status, .. } if status.as_u16() == 404 => { + invalid_request(message) + } + RemotePluginCatalogError::InvalidPluginPath { .. } + | RemotePluginCatalogError::ArchiveTooLarge { .. } + | RemotePluginCatalogError::UnknownMarketplace { .. } => invalid_request(message), + RemotePluginCatalogError::AuthToken(_) + | RemotePluginCatalogError::Request { .. } + | RemotePluginCatalogError::UnexpectedStatus { .. } + | RemotePluginCatalogError::Decode { .. } + | RemotePluginCatalogError::InvalidBaseUrl(_) + | RemotePluginCatalogError::InvalidBaseUrlPath + | RemotePluginCatalogError::UnexpectedPluginId { .. } + | RemotePluginCatalogError::UnexpectedSkillName { .. } + | RemotePluginCatalogError::UnexpectedEnabledState { .. } + | RemotePluginCatalogError::Archive { .. } + | RemotePluginCatalogError::ArchiveJoin(_) + | RemotePluginCatalogError::MissingUploadEtag + | RemotePluginCatalogError::UnexpectedResponse(_) + | RemotePluginCatalogError::CacheRemove(_) => internal_error(message), + } +} + +fn remote_plugin_bundle_install_error_to_jsonrpc( + err: codex_core_plugins::remote_bundle::RemotePluginBundleInstallError, +) -> JSONRPCErrorError { + internal_error(format!("install remote plugin bundle: {err}")) +} diff --git a/codex-rs/app-server/src/request_processors/process_exec_processor.rs b/codex-rs/app-server/src/request_processors/process_exec_processor.rs new file mode 100644 index 0000000000..5742d0e4d5 --- /dev/null +++ b/codex-rs/app-server/src/request_processors/process_exec_processor.rs @@ -0,0 +1,708 @@ +use std::collections::HashMap; +use std::collections::hash_map::Entry; +use std::sync::Arc; +use std::time::Duration; + +use base64::Engine; +use base64::engine::general_purpose::STANDARD; +use codex_app_server_protocol::ClientResponsePayload; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::ProcessExitedNotification; +use codex_app_server_protocol::ProcessKillParams; +use codex_app_server_protocol::ProcessKillResponse; +use codex_app_server_protocol::ProcessOutputDeltaNotification; +use codex_app_server_protocol::ProcessOutputStream; +use codex_app_server_protocol::ProcessResizePtyParams; +use codex_app_server_protocol::ProcessResizePtyResponse; +use codex_app_server_protocol::ProcessSpawnParams; +use codex_app_server_protocol::ProcessSpawnResponse; +use codex_app_server_protocol::ProcessTerminalSize; +use codex_app_server_protocol::ProcessWriteStdinParams; +use codex_app_server_protocol::ProcessWriteStdinResponse; +use codex_app_server_protocol::ServerNotification; +use codex_core::exec::ExecExpiration; +use codex_core::exec::ExecExpirationOutcome; +use codex_core::exec::IO_DRAIN_TIMEOUT_MS; +use codex_protocol::exec_output::bytes_to_string_smart; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; +use codex_utils_pty::ProcessHandle; +use codex_utils_pty::SpawnedProcess; +use codex_utils_pty::TerminalSize; +use tokio::sync::Mutex; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::sync::watch; +use tokio_util::sync::CancellationToken; + +use crate::error_code::internal_error; +use crate::error_code::invalid_params; +use crate::error_code::invalid_request; +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::ConnectionRequestId; +use crate::outgoing_message::OutgoingMessageSender; + +const EXEC_TIMEOUT_EXIT_CODE: i32 = 124; +const OUTPUT_CHUNK_SIZE_HINT: usize = 64 * 1024; + +#[derive(Clone)] +pub(crate) struct ProcessExecRequestProcessor { + outgoing: Arc, + process_exec_manager: ProcessExecManager, +} + +impl ProcessExecRequestProcessor { + pub(crate) fn new(outgoing: Arc) -> Self { + Self { + outgoing, + process_exec_manager: ProcessExecManager::default(), + } + } + + pub(crate) async fn process_spawn( + &self, + request_id: ConnectionRequestId, + params: ProcessSpawnParams, + ) -> Result<(), JSONRPCErrorError> { + let ProcessSpawnParams { + command, + process_handle, + cwd, + tty, + stream_stdin, + stream_stdout_stderr, + output_bytes_cap, + timeout_ms, + env: env_overrides, + size, + } = params; + let method_name = "process/spawn"; + tracing::debug!("{method_name} command: {command:?}"); + if command.is_empty() { + return Err(invalid_request("command must not be empty")); + } + if process_handle.is_empty() { + return Err(invalid_request("processHandle must not be empty")); + } + if size.is_some() && !tty { + return Err(invalid_params("process/spawn size requires tty: true")); + } + let mut env = std::env::vars().collect::>(); + if let Some(env_overrides) = env_overrides { + for (key, value) in env_overrides { + match value { + Some(value) => { + env.insert(key, value); + } + None => { + env.remove(&key); + } + } + } + } + let expiration = match timeout_ms { + Some(Some(timeout_ms)) => match u64::try_from(timeout_ms) { + Ok(timeout_ms) => timeout_ms.into(), + Err(_) => { + return Err(invalid_params(format!( + "{method_name} timeoutMs must be non-negative, got {timeout_ms}" + ))); + } + }, + Some(None) => ExecExpiration::Cancellation(CancellationToken::new()), + None => ExecExpiration::DefaultTimeout, + }; + let output_bytes_cap = output_bytes_cap.unwrap_or(Some(DEFAULT_OUTPUT_BYTES_CAP)); + let size = size.map(terminal_size_from_protocol).transpose()?; + + self.process_exec_manager + .start(StartProcessParams { + outgoing: self.outgoing.clone(), + request_id, + process_handle, + command, + cwd, + env, + expiration, + tty, + stream_stdin, + stream_stdout_stderr, + output_bytes_cap, + size, + }) + .await?; + + Ok(()) + } + + pub(crate) async fn process_write_stdin( + &self, + request_id: ConnectionRequestId, + params: ProcessWriteStdinParams, + ) -> Result, JSONRPCErrorError> { + self.process_exec_manager + .write_stdin(request_id, params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn process_resize_pty( + &self, + request_id: ConnectionRequestId, + params: ProcessResizePtyParams, + ) -> Result, JSONRPCErrorError> { + self.process_exec_manager + .resize_pty(request_id, params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn process_kill( + &self, + request_id: ConnectionRequestId, + params: ProcessKillParams, + ) -> Result, JSONRPCErrorError> { + self.process_exec_manager + .kill(request_id, params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) { + self.process_exec_manager + .connection_closed(connection_id) + .await; + } +} + +#[derive(Clone, Default)] +struct ProcessExecManager { + sessions: Arc>>, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct ConnectionProcessHandle { + connection_id: ConnectionId, + process_handle: String, +} + +#[derive(Clone)] +struct ProcessSession { + control_tx: mpsc::Sender, +} + +enum ProcessControl { + Write { delta: Vec, close_stdin: bool }, + Resize { size: TerminalSize }, + Kill, +} + +struct ProcessControlRequest { + control: ProcessControl, + response_tx: Option>>, +} + +struct StartProcessParams { + outgoing: Arc, + request_id: ConnectionRequestId, + process_handle: String, + command: Vec, + cwd: AbsolutePathBuf, + env: HashMap, + expiration: ExecExpiration, + tty: bool, + stream_stdin: bool, + stream_stdout_stderr: bool, + output_bytes_cap: Option, + size: Option, +} + +struct RunProcessParams { + outgoing: Arc, + request_id: ConnectionRequestId, + process_handle: String, + spawned: SpawnedProcess, + control_rx: mpsc::Receiver, + stream_stdin: bool, + stream_stdout_stderr: bool, + expiration: ExecExpiration, + output_bytes_cap: Option, +} + +struct SpawnProcessOutputParams { + connection_id: ConnectionId, + process_handle: String, + output_rx: mpsc::Receiver>, + stdio_timeout_rx: watch::Receiver, + outgoing: Arc, + stream: ProcessOutputStream, + stream_output: bool, + output_bytes_cap: Option, +} + +#[derive(Default)] +struct ProcessOutputCapture { + text: String, + cap_reached: bool, +} + +impl ProcessExecManager { + async fn start(&self, params: StartProcessParams) -> Result<(), JSONRPCErrorError> { + let StartProcessParams { + outgoing, + request_id, + process_handle, + command, + cwd, + env, + expiration, + tty, + stream_stdin, + stream_stdout_stderr, + output_bytes_cap, + size, + } = params; + + let (program, args) = command + .split_first() + .ok_or_else(|| invalid_request("command must not be empty"))?; + let stream_stdin = tty || stream_stdin; + let stream_stdout_stderr = tty || stream_stdout_stderr; + let arg0 = None; + let (control_tx, control_rx) = mpsc::channel(32); + let process_key = ConnectionProcessHandle { + connection_id: request_id.connection_id, + process_handle: process_handle.clone(), + }; + + { + let mut sessions = self.sessions.lock().await; + match sessions.entry(process_key.clone()) { + Entry::Occupied(_) => { + return Err(invalid_request(format!( + "duplicate active process handle: {process_handle:?}", + ))); + } + Entry::Vacant(entry) => { + entry.insert(ProcessSession { control_tx }); + } + } + } + + let spawned = if tty { + codex_utils_pty::spawn_pty_process( + program, + args, + cwd.as_path(), + &env, + &arg0, + size.unwrap_or_default(), + ) + .await + } else if stream_stdin { + codex_utils_pty::spawn_pipe_process(program, args, cwd.as_path(), &env, &arg0).await + } else { + codex_utils_pty::spawn_pipe_process_no_stdin(program, args, cwd.as_path(), &env, &arg0) + .await + }; + let spawned = match spawned { + Ok(spawned) => spawned, + Err(err) => { + self.sessions.lock().await.remove(&process_key); + return Err(internal_error(format!("failed to spawn process: {err}"))); + } + }; + + outgoing + .send_response(request_id.clone(), ProcessSpawnResponse {}) + .await; + + let sessions = Arc::clone(&self.sessions); + tokio::spawn(async move { + run_process(RunProcessParams { + outgoing, + request_id, + process_handle, + spawned, + control_rx, + stream_stdin, + stream_stdout_stderr, + expiration, + output_bytes_cap, + }) + .await; + sessions.lock().await.remove(&process_key); + }); + + Ok(()) + } + + async fn write_stdin( + &self, + request_id: ConnectionRequestId, + params: ProcessWriteStdinParams, + ) -> Result { + if params.delta_base64.is_none() && !params.close_stdin { + return Err(invalid_params( + "process/writeStdin requires deltaBase64 or closeStdin", + )); + } + + let delta = match params.delta_base64 { + Some(delta_base64) => STANDARD + .decode(delta_base64) + .map_err(|err| invalid_params(format!("invalid deltaBase64: {err}")))?, + None => Vec::new(), + }; + + self.send_control( + request_id.connection_id, + params.process_handle, + ProcessControl::Write { + delta, + close_stdin: params.close_stdin, + }, + ) + .await?; + + Ok(ProcessWriteStdinResponse {}) + } + + async fn kill( + &self, + request_id: ConnectionRequestId, + params: ProcessKillParams, + ) -> Result { + self.send_control( + request_id.connection_id, + params.process_handle, + ProcessControl::Kill, + ) + .await?; + Ok(ProcessKillResponse {}) + } + + async fn resize_pty( + &self, + request_id: ConnectionRequestId, + params: ProcessResizePtyParams, + ) -> Result { + self.send_control( + request_id.connection_id, + params.process_handle, + ProcessControl::Resize { + size: terminal_size_from_protocol(params.size)?, + }, + ) + .await?; + Ok(ProcessResizePtyResponse {}) + } + + async fn connection_closed(&self, connection_id: ConnectionId) { + let controls = { + let mut sessions = self.sessions.lock().await; + let process_handles = sessions + .keys() + .filter(|process_handle| process_handle.connection_id == connection_id) + .cloned() + .collect::>(); + let mut controls = Vec::with_capacity(process_handles.len()); + for process_handle in process_handles { + if let Some(control) = sessions.remove(&process_handle) { + controls.push(control); + } + } + controls + }; + + for control in controls { + let _ = control + .control_tx + .send(ProcessControlRequest { + control: ProcessControl::Kill, + response_tx: None, + }) + .await; + } + } + + async fn send_control( + &self, + connection_id: ConnectionId, + process_handle: String, + control: ProcessControl, + ) -> Result<(), JSONRPCErrorError> { + let process_key = ConnectionProcessHandle { + connection_id, + process_handle, + }; + let session = self + .sessions + .lock() + .await + .get(&process_key) + .cloned() + .ok_or_else(|| no_active_process_error(&process_key.process_handle))?; + let (response_tx, response_rx) = oneshot::channel(); + session + .control_tx + .send(ProcessControlRequest { + control, + response_tx: Some(response_tx), + }) + .await + .map_err(|_| process_no_longer_running_error(&process_key.process_handle))?; + response_rx + .await + .map_err(|_| process_no_longer_running_error(&process_key.process_handle))? + } +} + +async fn run_process(params: RunProcessParams) { + let RunProcessParams { + outgoing, + request_id, + process_handle, + spawned, + control_rx, + stream_stdin, + stream_stdout_stderr, + expiration, + output_bytes_cap, + } = params; + let mut control_rx = control_rx; + let mut control_open = true; + let expiration = expiration.wait_with_outcome(); + tokio::pin!(expiration); + let SpawnedProcess { + session, + stdout_rx, + stderr_rx, + exit_rx, + } = spawned; + tokio::pin!(exit_rx); + let mut expiration_outcome = None; + let (stdio_timeout_tx, stdio_timeout_rx) = watch::channel(false); + + let stdout_handle = collect_spawn_process_output(SpawnProcessOutputParams { + connection_id: request_id.connection_id, + process_handle: process_handle.clone(), + output_rx: stdout_rx, + stdio_timeout_rx: stdio_timeout_rx.clone(), + outgoing: Arc::clone(&outgoing), + stream: ProcessOutputStream::Stdout, + stream_output: stream_stdout_stderr, + output_bytes_cap, + }); + let stderr_handle = collect_spawn_process_output(SpawnProcessOutputParams { + connection_id: request_id.connection_id, + process_handle: process_handle.clone(), + output_rx: stderr_rx, + stdio_timeout_rx, + outgoing: Arc::clone(&outgoing), + stream: ProcessOutputStream::Stderr, + stream_output: stream_stdout_stderr, + output_bytes_cap, + }); + + let exit_code = loop { + tokio::select! { + control = control_rx.recv(), if control_open => { + match control { + Some(ProcessControlRequest { control, response_tx }) => { + let result = match control { + ProcessControl::Write { delta, close_stdin } => { + handle_process_write( + &session, + stream_stdin, + delta, + close_stdin, + ).await + } + ProcessControl::Resize { size } => { + handle_process_resize(&session, size) + } + ProcessControl::Kill => { + session.request_terminate(); + Ok(()) + } + }; + if let Some(response_tx) = response_tx + && response_tx.send(result).is_err() + { + tracing::debug!( + process_handle = %process_handle, + "process control response receiver dropped" + ); + } + }, + None => { + control_open = false; + session.request_terminate(); + } + } + } + outcome = &mut expiration, if expiration_outcome.is_none() => { + expiration_outcome = Some(outcome); + session.request_terminate(); + } + exit = &mut exit_rx => { + if matches!(expiration_outcome, Some(ExecExpirationOutcome::TimedOut)) { + break EXEC_TIMEOUT_EXIT_CODE; + } else { + break exit.unwrap_or(-1); + } + } + } + }; + + // Give stdout/stderr readers a bounded grace period to drain after process exit. + let timeout_handle = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(IO_DRAIN_TIMEOUT_MS)).await; + let _ = stdio_timeout_tx.send(true); + }); + + let stdout = stdout_handle.await.unwrap_or_default(); + let stderr = stderr_handle.await.unwrap_or_default(); + timeout_handle.abort(); + + outgoing + .send_server_notification_to_connection_and_wait( + request_id.connection_id, + ServerNotification::ProcessExited(ProcessExitedNotification { + process_handle, + exit_code, + stdout: stdout.text, + stdout_cap_reached: stdout.cap_reached, + stderr: stderr.text, + stderr_cap_reached: stderr.cap_reached, + }), + ) + .await; +} + +fn collect_spawn_process_output( + params: SpawnProcessOutputParams, +) -> tokio::task::JoinHandle { + let SpawnProcessOutputParams { + connection_id, + process_handle, + mut output_rx, + mut stdio_timeout_rx, + outgoing, + stream, + stream_output, + output_bytes_cap, + } = params; + tokio::spawn(async move { + let mut buffer: Vec = Vec::new(); + let mut observed_num_bytes = 0usize; + let mut cap_reached = false; + loop { + let mut chunk = tokio::select! { + chunk = output_rx.recv() => match chunk { + Some(chunk) => chunk, + None => break, + }, + _ = stdio_timeout_rx.wait_for(|&v| v) => break, + }; + while chunk.len() < OUTPUT_CHUNK_SIZE_HINT + && let Ok(next_chunk) = output_rx.try_recv() + { + chunk.extend_from_slice(&next_chunk); + } + let capped_chunk = match output_bytes_cap { + Some(output_bytes_cap) => { + let capped_chunk_len = output_bytes_cap + .saturating_sub(observed_num_bytes) + .min(chunk.len()); + observed_num_bytes += capped_chunk_len; + &chunk[0..capped_chunk_len] + } + None => chunk.as_slice(), + }; + cap_reached = Some(observed_num_bytes) == output_bytes_cap; + if stream_output { + outgoing + .send_server_notification_to_connection_and_wait( + connection_id, + ServerNotification::ProcessOutputDelta(ProcessOutputDeltaNotification { + process_handle: process_handle.clone(), + stream, + delta_base64: STANDARD.encode(capped_chunk), + cap_reached, + }), + ) + .await; + } else { + buffer.extend_from_slice(capped_chunk); + } + if cap_reached { + break; + } + } + ProcessOutputCapture { + text: bytes_to_string_smart(&buffer), + cap_reached, + } + }) +} + +async fn handle_process_write( + session: &ProcessHandle, + stream_stdin: bool, + delta: Vec, + close_stdin: bool, +) -> Result<(), JSONRPCErrorError> { + if !stream_stdin { + return Err(invalid_request( + "stdin streaming is not enabled for this process", + )); + } + if !delta.is_empty() { + session + .writer_sender() + .send(delta) + .await + .map_err(|_| invalid_request("stdin is already closed"))?; + } + if close_stdin { + // Closing drops our sender; the writer task still drains any bytes + // accepted above before its receiver observes EOF and closes stdin. + session.close_stdin(); + } + Ok(()) +} + +fn handle_process_resize( + session: &ProcessHandle, + size: TerminalSize, +) -> Result<(), JSONRPCErrorError> { + session + .resize(size) + .map_err(|err| invalid_request(format!("failed to resize PTY: {err}"))) +} + +fn terminal_size_from_protocol( + size: ProcessTerminalSize, +) -> Result { + if size.rows == 0 || size.cols == 0 { + return Err(invalid_params( + "process size rows and cols must be greater than 0", + )); + } + Ok(TerminalSize { + rows: size.rows, + cols: size.cols, + }) +} + +fn no_active_process_error(process_handle: &str) -> JSONRPCErrorError { + invalid_request(format!( + "no active process for process handle {process_handle:?}" + )) +} + +fn process_no_longer_running_error(process_handle: &str) -> JSONRPCErrorError { + invalid_request(format!("process {process_handle:?} is no longer running")) +} diff --git a/codex-rs/app-server/src/request_processors/request_errors.rs b/codex-rs/app-server/src/request_processors/request_errors.rs new file mode 100644 index 0000000000..18082aebe8 --- /dev/null +++ b/codex-rs/app-server/src/request_processors/request_errors.rs @@ -0,0 +1,8 @@ +use super::*; + +pub(super) fn environment_selection_error_message(err: CodexErr) -> String { + match err { + CodexErr::InvalidRequest(message) => message, + err => err.to_string(), + } +} diff --git a/codex-rs/app-server/src/request_processors/search.rs b/codex-rs/app-server/src/request_processors/search.rs new file mode 100644 index 0000000000..d683c6f10a --- /dev/null +++ b/codex-rs/app-server/src/request_processors/search.rs @@ -0,0 +1,134 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; + +use crate::error_code::internal_error; +use crate::error_code::invalid_request; +use crate::fuzzy_file_search::FuzzyFileSearchSession; +use crate::fuzzy_file_search::run_fuzzy_file_search; +use crate::fuzzy_file_search::start_fuzzy_file_search_session; +use crate::outgoing_message::OutgoingMessageSender; +use codex_app_server_protocol::FuzzyFileSearchParams; +use codex_app_server_protocol::FuzzyFileSearchResponse; +use codex_app_server_protocol::FuzzyFileSearchSessionStartParams; +use codex_app_server_protocol::FuzzyFileSearchSessionStartResponse; +use codex_app_server_protocol::FuzzyFileSearchSessionStopParams; +use codex_app_server_protocol::FuzzyFileSearchSessionStopResponse; +use codex_app_server_protocol::FuzzyFileSearchSessionUpdateParams; +use codex_app_server_protocol::FuzzyFileSearchSessionUpdateResponse; +use codex_app_server_protocol::JSONRPCErrorError; +use tokio::sync::Mutex; + +#[derive(Clone)] +pub(crate) struct SearchRequestProcessor { + outgoing: Arc, + pending_fuzzy_searches: Arc>>>, + fuzzy_search_sessions: Arc>>, +} + +impl SearchRequestProcessor { + pub(crate) fn new(outgoing: Arc) -> Self { + Self { + outgoing, + pending_fuzzy_searches: Arc::new(Mutex::new(HashMap::new())), + fuzzy_search_sessions: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub(crate) async fn fuzzy_file_search( + &self, + params: FuzzyFileSearchParams, + ) -> Result { + let FuzzyFileSearchParams { + query, + roots, + cancellation_token, + } = params; + + let cancel_flag = match cancellation_token.clone() { + Some(token) => { + let mut pending_fuzzy_searches = self.pending_fuzzy_searches.lock().await; + // if a cancellation_token is provided and a pending_request exists for + // that token, cancel it + if let Some(existing) = pending_fuzzy_searches.get(&token) { + existing.store(true, Ordering::Relaxed); + } + let flag = Arc::new(AtomicBool::new(false)); + pending_fuzzy_searches.insert(token.clone(), flag.clone()); + flag + } + None => Arc::new(AtomicBool::new(false)), + }; + + let results = match query.as_str() { + "" => vec![], + _ => run_fuzzy_file_search(query, roots, cancel_flag.clone()).await, + }; + + if let Some(token) = cancellation_token { + let mut pending_fuzzy_searches = self.pending_fuzzy_searches.lock().await; + if let Some(current_flag) = pending_fuzzy_searches.get(&token) + && Arc::ptr_eq(current_flag, &cancel_flag) + { + pending_fuzzy_searches.remove(&token); + } + } + + Ok(FuzzyFileSearchResponse { files: results }) + } + + pub(crate) async fn fuzzy_file_search_session_start_response( + &self, + params: FuzzyFileSearchSessionStartParams, + ) -> Result { + let FuzzyFileSearchSessionStartParams { session_id, roots } = params; + if session_id.is_empty() { + return Err(invalid_request("sessionId must not be empty")); + } + + let session = + start_fuzzy_file_search_session(session_id.clone(), roots, self.outgoing.clone()) + .map_err(|err| { + internal_error(format!("failed to start fuzzy file search session: {err}")) + })?; + self.fuzzy_search_sessions + .lock() + .await + .insert(session_id, session); + Ok(FuzzyFileSearchSessionStartResponse {}) + } + + pub(crate) async fn fuzzy_file_search_session_update_response( + &self, + params: FuzzyFileSearchSessionUpdateParams, + ) -> Result { + let FuzzyFileSearchSessionUpdateParams { session_id, query } = params; + let found = { + let sessions = self.fuzzy_search_sessions.lock().await; + if let Some(session) = sessions.get(&session_id) { + session.update_query(query); + true + } else { + false + } + }; + if !found { + return Err(invalid_request(format!( + "fuzzy file search session not found: {session_id}" + ))); + } + + Ok(FuzzyFileSearchSessionUpdateResponse {}) + } + + pub(crate) async fn fuzzy_file_search_session_stop( + &self, + params: FuzzyFileSearchSessionStopParams, + ) -> Result { + let FuzzyFileSearchSessionStopParams { session_id } = params; + self.fuzzy_search_sessions.lock().await.remove(&session_id); + + Ok(FuzzyFileSearchSessionStopResponse {}) + } +} diff --git a/codex-rs/app-server/src/codex_message_processor/thread_goal_handlers.rs b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs similarity index 51% rename from codex-rs/app-server/src/codex_message_processor/thread_goal_handlers.rs rename to codex-rs/app-server/src/request_processors/thread_goal_processor.rs index 049e0af21c..57d2575590 100644 --- a/codex-rs/app-server/src/codex_message_processor/thread_goal_handlers.rs +++ b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs @@ -1,68 +1,122 @@ use super::*; use codex_protocol::protocol::validate_thread_goal_objective; -impl CodexMessageProcessor { - pub(super) async fn thread_goal_set( +#[derive(Clone)] +pub(crate) struct ThreadGoalRequestProcessor { + thread_manager: Arc, + outgoing: Arc, + config: Arc, + thread_state_manager: ThreadStateManager, + state_db: Option, +} + +impl ThreadGoalRequestProcessor { + pub(crate) fn new( + thread_manager: Arc, + outgoing: Arc, + config: Arc, + thread_state_manager: ThreadStateManager, + state_db: Option, + ) -> Self { + Self { + thread_manager, + outgoing, + config, + thread_state_manager, + state_db, + } + } + + pub(crate) async fn thread_goal_set( &self, request_id: ConnectionRequestId, params: ThreadGoalSetParams, + ) -> Result, JSONRPCErrorError> { + self.thread_goal_set_inner(request_id, params) + .await + .map(|()| None) + } + + pub(crate) async fn thread_goal_get( + &self, + params: ThreadGoalGetParams, + ) -> Result, JSONRPCErrorError> { + self.thread_goal_get_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_goal_clear( + &self, + request_id: ConnectionRequestId, + params: ThreadGoalClearParams, + ) -> Result, JSONRPCErrorError> { + self.thread_goal_clear_inner(request_id, params) + .await + .map(|()| None) + } + + pub(crate) async fn emit_resume_goal_snapshot_and_continue( + &self, + thread_id: ThreadId, + thread: &CodexThread, ) { if !self.config.features.enabled(Feature::Goals) { - self.send_invalid_request_error(request_id, "goals feature is disabled".to_string()) - .await; return; } + self.emit_thread_goal_snapshot(thread_id).await; + // App-server owns resume response and snapshot ordering, so wait until + // those are sent before letting core start goal continuation. + if let Err(err) = thread.continue_active_goal_if_idle().await { + tracing::warn!("failed to continue active goal after resume: {err}"); + } + } - let thread_id = match parse_thread_id_for_request(params.thread_id.as_str()) { - Ok(thread_id) => thread_id, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - let state_db = match self.state_db_for_materialized_thread(thread_id).await { - Ok(state_db) => state_db, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; + pub(crate) async fn pending_resume_goal_state( + &self, + thread: &CodexThread, + ) -> (bool, Option) { + let emit_thread_goal_update = self.config.features.enabled(Feature::Goals); + let thread_goal_state_db = if emit_thread_goal_update { + if let Some(state_db) = thread.state_db() { + Some(state_db) + } else { + self.state_db.clone() } + } else { + None }; + (emit_thread_goal_update, thread_goal_state_db) + } + + async fn thread_goal_set_inner( + &self, + request_id: ConnectionRequestId, + params: ThreadGoalSetParams, + ) -> Result<(), JSONRPCErrorError> { + if !self.config.features.enabled(Feature::Goals) { + return Err(invalid_request("goals feature is disabled")); + } + + let thread_id = parse_thread_id_for_request(params.thread_id.as_str())?; + let state_db = self.state_db_for_materialized_thread(thread_id).await?; let running_thread = self.thread_manager.get_thread(thread_id).await.ok(); let rollout_path = match running_thread.as_ref() { - Some(thread) => match thread.rollout_path() { - Some(path) => path, - None => { - self.send_invalid_request_error( - request_id, - format!("ephemeral thread does not support goals: {thread_id}"), - ) - .await; - return; - } - }, - None => { - match find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()) - .await - { - Ok(Some(path)) => path, - Ok(None) => { - self.send_invalid_request_error( - request_id, - format!("thread not found: {thread_id}"), - ) - .await; - return; - } - Err(err) => { - self.send_internal_error( - request_id, - format!("failed to locate thread id {thread_id}: {err}"), - ) - .await; - return; - } - } - } + Some(thread) => thread.rollout_path().ok_or_else(|| { + invalid_request(format!( + "ephemeral thread does not support goals: {thread_id}" + )) + })?, + None => codex_rollout::find_thread_path_by_id_str( + &self.config.codex_home, + &thread_id.to_string(), + self.state_db.as_deref(), + ) + .await + .map_err(|err| { + internal_error(format!("failed to locate thread id {thread_id}: {err}")) + })? + .ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?, }; reconcile_rollout( Some(&state_db), @@ -84,67 +138,70 @@ impl CodexMessageProcessor { let objective = params.objective.as_deref().map(str::trim); if let Some(objective) = objective { - if let Err(message) = validate_thread_goal_objective(objective) { - self.send_invalid_request_error(request_id, message).await; - return; - } - if let Err(message) = validate_goal_budget(params.token_budget.flatten()) { - self.send_invalid_request_error(request_id, message).await; - return; - } - } else if let Some(token_budget) = params.token_budget - && let Err(message) = validate_goal_budget(token_budget) - { - self.send_invalid_request_error(request_id, message).await; - return; + validate_thread_goal_objective(objective).map_err(invalid_request)?; + } + if objective.is_some() || params.token_budget.is_some() { + validate_goal_budget(params.token_budget.flatten()).map_err(invalid_request)?; } if let Some(thread) = running_thread.as_ref() { thread.prepare_external_goal_mutation().await; } - let goal = if let Some(objective) = objective { - match state_db.get_thread_goal(thread_id).await { - Ok(goal) => { - if let Some(goal) = goal.as_ref().filter(|goal| { - goal.objective == objective - && goal.status != codex_state::ThreadGoalStatus::Complete - }) { - state_db - .update_thread_goal( - thread_id, - codex_state::ThreadGoalUpdate { - status, - token_budget: params.token_budget, - expected_goal_id: Some(goal.goal_id.clone()), - }, + let (goal, previous_status) = (if let Some(objective) = objective { + let existing_goal = state_db + .get_thread_goal(thread_id) + .await + .map_err(|err| invalid_request(err.to_string()))?; + if let Some(goal) = existing_goal.as_ref() { + let previous_status = ExternalGoalPreviousStatus::from(goal); + state_db + .update_thread_goal( + thread_id, + codex_state::ThreadGoalUpdate { + objective: Some(objective.to_string()), + status, + token_budget: params.token_budget, + expected_goal_id: Some(goal.goal_id.clone()), + }, + ) + .await + .and_then(|goal| { + goal.ok_or_else(|| { + anyhow::anyhow!( + "cannot update goal for thread {thread_id}: no goal exists" ) - .await - .and_then(|goal| { - goal.ok_or_else(|| { - anyhow::anyhow!( - "cannot update goal for thread {thread_id}: no goal exists" - ) - }) - }) - } else { - state_db - .replace_thread_goal( - thread_id, - objective, - status.unwrap_or(codex_state::ThreadGoalStatus::Active), - params.token_budget.flatten(), - ) - .await - } - } - Err(err) => Err(err), + }) + }) + .map(|goal| (goal, previous_status)) + } else { + let previous_status = ExternalGoalPreviousStatus::NewGoal; + state_db + .replace_thread_goal( + thread_id, + objective, + status.unwrap_or(codex_state::ThreadGoalStatus::Active), + params.token_budget.flatten(), + ) + .await + .map(|goal| (goal, previous_status)) } } else { + let existing_goal = state_db + .get_thread_goal(thread_id) + .await + .map_err(|err| invalid_request(err.to_string()))?; + let Some(existing_goal) = existing_goal else { + return Err(invalid_request(format!( + "cannot update goal for thread {thread_id}: no goal exists" + ))); + }; + let previous_status = ExternalGoalPreviousStatus::from(&existing_goal); state_db .update_thread_goal( thread_id, codex_state::ThreadGoalUpdate { + objective: None, status, token_budget: params.token_budget, expected_goal_id: None, @@ -156,17 +213,13 @@ impl CodexMessageProcessor { anyhow::anyhow!("cannot update goal for thread {thread_id}: no goal exists") }) }) + .map(|goal| (goal, previous_status)) + }) + .map_err(|err| invalid_request(err.to_string()))?; + let external_goal_set = ExternalGoalSet { + goal: goal.clone(), + previous_status, }; - - let goal = match goal { - Ok(goal) => goal, - Err(err) => { - self.send_invalid_request_error(request_id, err.to_string()) - .await; - return; - } - }; - let goal_status = goal.status; let goal = api_thread_goal_from_state(goal); self.outgoing .send_response( @@ -177,109 +230,57 @@ impl CodexMessageProcessor { self.emit_thread_goal_updated_ordered(thread_id, goal, listener_command_tx) .await; if let Some(thread) = running_thread.as_ref() { - thread.apply_external_goal_set(goal_status).await; + thread.apply_external_goal_set(external_goal_set).await; } + Ok(()) } - pub(super) async fn thread_goal_get( + async fn thread_goal_get_inner( &self, - request_id: ConnectionRequestId, params: ThreadGoalGetParams, - ) { + ) -> Result { if !self.config.features.enabled(Feature::Goals) { - self.send_invalid_request_error(request_id, "goals feature is disabled".to_string()) - .await; - return; + return Err(invalid_request("goals feature is disabled")); } - let thread_id = match parse_thread_id_for_request(params.thread_id.as_str()) { - Ok(thread_id) => thread_id, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - let state_db = match self.state_db_for_materialized_thread(thread_id).await { - Ok(state_db) => state_db, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - let goal = match state_db.get_thread_goal(thread_id).await { - Ok(goal) => goal.map(api_thread_goal_from_state), - Err(err) => { - self.send_internal_error(request_id, format!("failed to read thread goal: {err}")) - .await; - return; - } - }; - self.outgoing - .send_response(request_id, ThreadGoalGetResponse { goal }) - .await; + let thread_id = parse_thread_id_for_request(params.thread_id.as_str())?; + let state_db = self.state_db_for_materialized_thread(thread_id).await?; + let goal = state_db + .get_thread_goal(thread_id) + .await + .map_err(|err| internal_error(format!("failed to read thread goal: {err}")))? + .map(api_thread_goal_from_state); + Ok(ThreadGoalGetResponse { goal }) } - pub(super) async fn thread_goal_clear( + async fn thread_goal_clear_inner( &self, request_id: ConnectionRequestId, params: ThreadGoalClearParams, - ) { + ) -> Result<(), JSONRPCErrorError> { if !self.config.features.enabled(Feature::Goals) { - self.send_invalid_request_error(request_id, "goals feature is disabled".to_string()) - .await; - return; + return Err(invalid_request("goals feature is disabled")); } - let thread_id = match parse_thread_id_for_request(params.thread_id.as_str()) { - Ok(thread_id) => thread_id, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; - let state_db = match self.state_db_for_materialized_thread(thread_id).await { - Ok(state_db) => state_db, - Err(error) => { - self.outgoing.send_error(request_id, error).await; - return; - } - }; + let thread_id = parse_thread_id_for_request(params.thread_id.as_str())?; + let state_db = self.state_db_for_materialized_thread(thread_id).await?; let running_thread = self.thread_manager.get_thread(thread_id).await.ok(); let rollout_path = match running_thread.as_ref() { - Some(thread) => match thread.rollout_path() { - Some(path) => path, - None => { - self.send_invalid_request_error( - request_id, - format!("ephemeral thread does not support goals: {thread_id}"), - ) - .await; - return; - } - }, - None => { - match find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()) - .await - { - Ok(Some(path)) => path, - Ok(None) => { - self.send_invalid_request_error( - request_id, - format!("thread not found: {thread_id}"), - ) - .await; - return; - } - Err(err) => { - self.send_internal_error( - request_id, - format!("failed to locate thread id {thread_id}: {err}"), - ) - .await; - return; - } - } - } + Some(thread) => thread.rollout_path().ok_or_else(|| { + invalid_request(format!( + "ephemeral thread does not support goals: {thread_id}" + )) + })?, + None => codex_rollout::find_thread_path_by_id_str( + &self.config.codex_home, + &thread_id.to_string(), + self.state_db.as_deref(), + ) + .await + .map_err(|err| { + internal_error(format!("failed to locate thread id {thread_id}: {err}")) + })? + .ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?, }; reconcile_rollout( Some(&state_db), @@ -301,14 +302,10 @@ impl CodexMessageProcessor { let thread_state = thread_state.lock().await; thread_state.listener_command_tx() }; - let cleared = match state_db.delete_thread_goal(thread_id).await { - Ok(cleared) => cleared, - Err(err) => { - self.send_internal_error(request_id, format!("failed to clear thread goal: {err}")) - .await; - return; - } - }; + let cleared = state_db + .delete_thread_goal(thread_id) + .await + .map_err(|err| internal_error(format!("failed to clear thread goal: {err}")))?; if cleared && let Some(thread) = running_thread.as_ref() { thread.apply_external_goal_clear().await; @@ -321,6 +318,7 @@ impl CodexMessageProcessor { self.emit_thread_goal_cleared_ordered(thread_id, listener_command_tx) .await; } + Ok(()) } async fn state_db_for_materialized_thread( @@ -337,26 +335,24 @@ impl CodexMessageProcessor { return Ok(state_db); } } else { - match find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()).await - { - Ok(Some(_)) => {} - Ok(None) => { - return Err(invalid_request(format!("thread not found: {thread_id}"))); - } - Err(err) => { - return Err(internal_error(format!( - "failed to locate thread id {thread_id}: {err}" - ))); - } - } + codex_rollout::find_thread_path_by_id_str( + &self.config.codex_home, + &thread_id.to_string(), + self.state_db.as_deref(), + ) + .await + .map_err(|err| { + internal_error(format!("failed to locate thread id {thread_id}: {err}")) + })? + .ok_or_else(|| invalid_request(format!("thread not found: {thread_id}")))?; } - open_state_db_for_direct_thread_lookup(&self.config) - .await + self.state_db + .clone() .ok_or_else(|| internal_error("sqlite state db unavailable for thread goals")) } - pub(super) async fn emit_thread_goal_snapshot(&self, thread_id: ThreadId) { + async fn emit_thread_goal_snapshot(&self, thread_id: ThreadId) { let state_db = match self.state_db_for_materialized_thread(thread_id).await { Ok(state_db) => state_db, Err(err) => { @@ -477,3 +473,8 @@ pub(super) fn api_thread_goal_from_state(goal: codex_state::ThreadGoal) -> Threa updated_at: goal.updated_at.timestamp(), } } + +fn parse_thread_id_for_request(thread_id: &str) -> Result { + ThreadId::from_string(thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}"))) +} diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs new file mode 100644 index 0000000000..7dab206d85 --- /dev/null +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -0,0 +1,764 @@ +use super::*; + +pub(super) const THREAD_UNLOADING_DELAY: Duration = Duration::from_secs(30 * 60); + +#[derive(Clone)] +pub(super) struct ListenerTaskContext { + pub(super) thread_manager: Arc, + pub(super) thread_state_manager: ThreadStateManager, + pub(super) outgoing: Arc, + pub(super) pending_thread_unloads: Arc>>, + pub(super) thread_watch_manager: ThreadWatchManager, + pub(super) thread_list_state_permit: Arc, + pub(super) fallback_model_provider: String, + pub(super) codex_home: PathBuf, + pub(super) skills_watcher: Arc, +} + +struct UnloadingState { + delay: Duration, + has_subscribers_rx: watch::Receiver, + has_subscribers: (bool, Instant), + thread_status_rx: watch::Receiver, + is_active: (bool, Instant), +} + +impl UnloadingState { + async fn new( + listener_task_context: &ListenerTaskContext, + thread_id: ThreadId, + delay: Duration, + ) -> Option { + let has_subscribers_rx = listener_task_context + .thread_state_manager + .subscribe_to_has_connections(thread_id) + .await?; + let thread_status_rx = listener_task_context + .thread_watch_manager + .subscribe(thread_id) + .await?; + let has_subscribers = (*has_subscribers_rx.borrow(), Instant::now()); + let is_active = ( + matches!(*thread_status_rx.borrow(), ThreadStatus::Active { .. }), + Instant::now(), + ); + Some(Self { + delay, + has_subscribers_rx, + has_subscribers, + thread_status_rx, + is_active, + }) + } + + fn unloading_target(&self) -> Option { + match (self.has_subscribers, self.is_active) { + ((false, has_no_subscribers_since), (false, is_inactive_since)) => { + Some(std::cmp::max(has_no_subscribers_since, is_inactive_since) + self.delay) + } + _ => None, + } + } + + fn sync_receiver_values(&mut self) { + let has_subscribers = *self.has_subscribers_rx.borrow(); + if self.has_subscribers.0 != has_subscribers { + self.has_subscribers = (has_subscribers, Instant::now()); + } + + let is_active = matches!(*self.thread_status_rx.borrow(), ThreadStatus::Active { .. }); + if self.is_active.0 != is_active { + self.is_active = (is_active, Instant::now()); + } + } + + fn should_unload_now(&mut self) -> bool { + self.sync_receiver_values(); + self.unloading_target() + .is_some_and(|target| target <= Instant::now()) + } + + fn note_thread_activity_observed(&mut self) { + if !self.is_active.0 { + self.is_active = (false, Instant::now()); + } + } + + async fn wait_for_unloading_trigger(&mut self) -> bool { + loop { + self.sync_receiver_values(); + let unloading_target = self.unloading_target(); + if let Some(target) = unloading_target + && target <= Instant::now() + { + return true; + } + let unloading_sleep = async { + if let Some(target) = unloading_target { + tokio::time::sleep_until(target.into()).await; + } else { + futures::future::pending::<()>().await; + } + }; + tokio::select! { + _ = unloading_sleep => return true, + changed = self.has_subscribers_rx.changed() => { + if changed.is_err() { + return false; + } + self.sync_receiver_values(); + }, + changed = self.thread_status_rx.changed() => { + if changed.is_err() { + return false; + } + self.sync_receiver_values(); + }, + } + } + } +} + +pub(super) enum ThreadShutdownResult { + Complete, + SubmitFailed, + TimedOut, +} + +pub(super) enum EnsureConversationListenerResult { + Attached, + ConnectionClosed, +} + +#[expect( + clippy::await_holding_invalid_type, + reason = "listener subscription must be serialized against pending unloads" +)] +pub(super) async fn ensure_conversation_listener( + listener_task_context: ListenerTaskContext, + conversation_id: ThreadId, + connection_id: ConnectionId, + raw_events_enabled: bool, +) -> Result { + let conversation = match listener_task_context + .thread_manager + .get_thread(conversation_id) + .await + { + Ok(conv) => conv, + Err(_) => { + return Err(invalid_request(format!( + "thread not found: {conversation_id}" + ))); + } + }; + let thread_state = { + let pending_thread_unloads = listener_task_context.pending_thread_unloads.lock().await; + if pending_thread_unloads.contains(&conversation_id) { + return Err(invalid_request(format!( + "thread {conversation_id} is closing; retry after the thread is closed" + ))); + } + let Some(thread_state) = listener_task_context + .thread_state_manager + .try_ensure_connection_subscribed(conversation_id, connection_id, raw_events_enabled) + .await + else { + return Ok(EnsureConversationListenerResult::ConnectionClosed); + }; + thread_state + }; + if let Err(error) = ensure_listener_task_running( + listener_task_context.clone(), + conversation_id, + conversation, + thread_state, + ) + .await + { + let _ = listener_task_context + .thread_state_manager + .unsubscribe_connection_from_thread(conversation_id, connection_id) + .await; + return Err(error); + } + Ok(EnsureConversationListenerResult::Attached) +} + +pub(super) fn log_listener_attach_result( + result: Result, + thread_id: ThreadId, + connection_id: ConnectionId, + thread_kind: &'static str, +) { + match result { + Ok(EnsureConversationListenerResult::Attached) => {} + Ok(EnsureConversationListenerResult::ConnectionClosed) => { + tracing::debug!( + thread_id = %thread_id, + connection_id = ?connection_id, + "skipping auto-attach for closed connection" + ); + } + Err(err) => { + tracing::warn!( + "failed to attach listener for {thread_kind} {thread_id}: {message}", + message = err.message + ); + } + } +} + +pub(super) async fn ensure_listener_task_running( + listener_task_context: ListenerTaskContext, + conversation_id: ThreadId, + conversation: Arc, + thread_state: Arc>, +) -> Result<(), JSONRPCErrorError> { + let (cancel_tx, mut cancel_rx) = oneshot::channel(); + let Some(mut unloading_state) = UnloadingState::new( + &listener_task_context, + conversation_id, + THREAD_UNLOADING_DELAY, + ) + .await + else { + return Err(invalid_request(format!( + "thread {conversation_id} is closing; retry after the thread is closed" + ))); + }; + let config = conversation.config().await; + let environments = conversation.environment_selections().await; + let watch_registration = listener_task_context + .skills_watcher + .register_thread_config( + config.as_ref(), + listener_task_context.thread_manager.as_ref(), + &environments, + ) + .await; + let (mut listener_command_rx, listener_generation) = { + let mut thread_state = thread_state.lock().await; + if thread_state.listener_matches(&conversation) { + return Ok(()); + } + thread_state.set_listener(cancel_tx, &conversation, watch_registration) + }; + let ListenerTaskContext { + outgoing, + thread_manager, + thread_state_manager, + pending_thread_unloads, + thread_watch_manager, + thread_list_state_permit, + fallback_model_provider, + codex_home, + .. + } = listener_task_context; + let outgoing_for_task = Arc::clone(&outgoing); + tokio::spawn(async move { + loop { + tokio::select! { + biased; + _ = &mut cancel_rx => { + // Listener was superseded or the thread is being torn down. + break; + } + listener_command = listener_command_rx.recv() => { + let Some(listener_command) = listener_command else { + break; + }; + handle_thread_listener_command( + conversation_id, + &conversation, + codex_home.as_path(), + &thread_state_manager, + &thread_state, + &thread_watch_manager, + &outgoing_for_task, + &pending_thread_unloads, + listener_command, + ) + .await; + } + event = conversation.next_event() => { + let event = match event { + Ok(event) => event, + Err(err) => { + tracing::warn!("thread.next_event() failed with: {err}"); + break; + } + }; + + // Track the event before emitting any typed translations + // so thread-local state such as raw event opt-in stays + // synchronized with the conversation. + let raw_events_enabled = { + let mut thread_state = thread_state.lock().await; + thread_state.track_current_turn_event(&event.id, &event.msg); + thread_state.experimental_raw_events + }; + let subscribed_connection_ids = thread_state_manager + .subscribed_connection_ids(conversation_id) + .await; + let thread_outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing_for_task.clone(), + subscribed_connection_ids, + conversation_id, + ); + + if let EventMsg::RawResponseItem(raw_response_item_event) = &event.msg + && !raw_events_enabled + { + maybe_emit_hook_prompt_item_completed( + conversation_id, + &event.id, + &raw_response_item_event.item, + &thread_outgoing, + ) + .await; + continue; + } + + apply_bespoke_event_handling( + event.clone(), + conversation_id, + conversation.clone(), + thread_manager.clone(), + thread_outgoing, + thread_state.clone(), + thread_watch_manager.clone(), + thread_list_state_permit.clone(), + fallback_model_provider.clone(), + ) + .await; + } + unloading_watchers_open = unloading_state.wait_for_unloading_trigger() => { + if !unloading_watchers_open { + break; + } + if !unloading_state.should_unload_now() { + continue; + } + if matches!(conversation.agent_status().await, AgentStatus::Running) { + unloading_state.note_thread_activity_observed(); + continue; + } + { + let mut pending_thread_unloads = pending_thread_unloads.lock().await; + if pending_thread_unloads.contains(&conversation_id) { + continue; + } + if !unloading_state.should_unload_now() { + continue; + } + pending_thread_unloads.insert(conversation_id); + } + unload_thread_without_subscribers( + thread_manager.clone(), + outgoing_for_task.clone(), + pending_thread_unloads.clone(), + thread_state_manager.clone(), + thread_watch_manager.clone(), + conversation_id, + conversation.clone(), + ) + .await; + break; + } + } + } + + let mut thread_state = thread_state.lock().await; + if thread_state.listener_generation == listener_generation { + thread_state.clear_listener(); + } + }); + Ok(()) +} + +pub(super) async fn wait_for_thread_shutdown(thread: &Arc) -> ThreadShutdownResult { + match tokio::time::timeout(Duration::from_secs(10), thread.shutdown_and_wait()).await { + Ok(Ok(())) => ThreadShutdownResult::Complete, + Ok(Err(_)) => ThreadShutdownResult::SubmitFailed, + Err(_) => ThreadShutdownResult::TimedOut, + } +} + +pub(super) async fn unload_thread_without_subscribers( + thread_manager: Arc, + outgoing: Arc, + pending_thread_unloads: Arc>>, + thread_state_manager: ThreadStateManager, + thread_watch_manager: ThreadWatchManager, + thread_id: ThreadId, + thread: Arc, +) { + info!("thread {thread_id} has no subscribers and is idle; shutting down"); + + // Any pending app-server -> client requests for this thread can no longer be + // answered; cancel their callbacks before shutdown/unload. + outgoing + .cancel_requests_for_thread(thread_id, /*error*/ None) + .await; + thread_state_manager.remove_thread_state(thread_id).await; + + tokio::spawn(async move { + match wait_for_thread_shutdown(&thread).await { + ThreadShutdownResult::Complete => { + if thread_manager.remove_thread(&thread_id).await.is_none() { + info!("thread {thread_id} was already removed before teardown finalized"); + thread_watch_manager + .remove_thread(&thread_id.to_string()) + .await; + pending_thread_unloads.lock().await.remove(&thread_id); + return; + } + thread_watch_manager + .remove_thread(&thread_id.to_string()) + .await; + let notification = ThreadClosedNotification { + thread_id: thread_id.to_string(), + }; + outgoing + .send_server_notification(ServerNotification::ThreadClosed(notification)) + .await; + pending_thread_unloads.lock().await.remove(&thread_id); + } + ThreadShutdownResult::SubmitFailed => { + pending_thread_unloads.lock().await.remove(&thread_id); + warn!("failed to submit Shutdown to thread {thread_id}"); + } + ThreadShutdownResult::TimedOut => { + pending_thread_unloads.lock().await.remove(&thread_id); + warn!("thread {thread_id} shutdown timed out; leaving thread loaded"); + } + } + }); +} + +#[allow(clippy::too_many_arguments)] +pub(super) async fn handle_thread_listener_command( + conversation_id: ThreadId, + conversation: &Arc, + codex_home: &Path, + thread_state_manager: &ThreadStateManager, + thread_state: &Arc>, + thread_watch_manager: &ThreadWatchManager, + outgoing: &Arc, + pending_thread_unloads: &Arc>>, + listener_command: ThreadListenerCommand, +) { + match listener_command { + ThreadListenerCommand::SendThreadResumeResponse(resume_request) => { + handle_pending_thread_resume_request( + conversation_id, + conversation, + codex_home, + thread_state_manager, + thread_state, + thread_watch_manager, + outgoing, + pending_thread_unloads, + *resume_request, + ) + .await; + } + ThreadListenerCommand::EmitThreadGoalUpdated { goal } => { + outgoing + .send_server_notification(ServerNotification::ThreadGoalUpdated( + ThreadGoalUpdatedNotification { + thread_id: conversation_id.to_string(), + turn_id: None, + goal, + }, + )) + .await; + } + ThreadListenerCommand::EmitThreadGoalCleared => { + outgoing + .send_server_notification(ServerNotification::ThreadGoalCleared( + ThreadGoalClearedNotification { + thread_id: conversation_id.to_string(), + }, + )) + .await; + } + ThreadListenerCommand::EmitThreadGoalSnapshot { state_db } => { + send_thread_goal_snapshot_notification(outgoing, conversation_id, &state_db).await; + } + ThreadListenerCommand::ResolveServerRequest { + request_id, + completion_tx, + } => { + resolve_pending_server_request( + conversation_id, + thread_state_manager, + outgoing, + request_id, + ) + .await; + let _ = completion_tx.send(()); + } + } +} + +#[allow(clippy::too_many_arguments)] +#[expect( + clippy::await_holding_invalid_type, + reason = "running-thread resume subscription must be serialized against pending unloads" +)] +pub(super) async fn handle_pending_thread_resume_request( + conversation_id: ThreadId, + conversation: &Arc, + _codex_home: &Path, + thread_state_manager: &ThreadStateManager, + thread_state: &Arc>, + thread_watch_manager: &ThreadWatchManager, + outgoing: &Arc, + pending_thread_unloads: &Arc>>, + pending: crate::thread_state::PendingThreadResumeRequest, +) { + let active_turn = { + let state = thread_state.lock().await; + state.active_turn_snapshot() + }; + tracing::debug!( + thread_id = %conversation_id, + request_id = ?pending.request_id, + active_turn_present = active_turn.is_some(), + active_turn_id = ?active_turn.as_ref().map(|turn| turn.id.as_str()), + active_turn_status = ?active_turn.as_ref().map(|turn| &turn.status), + "composing running thread resume response" + ); + let has_live_in_progress_turn = + matches!(conversation.agent_status().await, AgentStatus::Running) + || active_turn + .as_ref() + .is_some_and(|turn| matches!(turn.status, TurnStatus::InProgress)); + + let request_id = pending.request_id; + let connection_id = request_id.connection_id; + let mut thread = pending.thread_summary; + if pending.include_turns { + populate_thread_turns_from_history( + &mut thread, + &pending.history_items, + active_turn.as_ref(), + ); + } + + let thread_status = thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await; + + set_thread_status_and_interrupt_stale_turns( + &mut thread, + thread_status, + has_live_in_progress_turn, + ); + let token_usage_thread = pending.include_turns.then(|| thread.clone()); + if pending.redact_resume_payloads { + redact_thread_resume_payloads(&mut thread); + } + + { + let pending_thread_unloads = pending_thread_unloads.lock().await; + if pending_thread_unloads.contains(&conversation_id) { + drop(pending_thread_unloads); + outgoing + .send_error( + request_id, + invalid_request(format!( + "thread {conversation_id} is closing; retry thread/resume after the thread is closed" + )), + ) + .await; + return; + } + if !thread_state_manager + .try_add_connection_to_thread(conversation_id, connection_id) + .await + { + tracing::debug!( + thread_id = %conversation_id, + connection_id = ?connection_id, + "skipping running thread resume for closed connection" + ); + return; + } + } + + if pending.emit_thread_goal_update + && let Err(err) = conversation.apply_goal_resume_runtime_effects().await + { + tracing::warn!("failed to apply goal resume runtime effects: {err}"); + } + + let ThreadConfigSnapshot { + model, + model_provider_id, + service_tier, + approval_policy, + approvals_reviewer, + permission_profile, + active_permission_profile, + cwd, + reasoning_effort, + .. + } = pending.config_snapshot; + let instruction_sources = pending.instruction_sources; + let sandbox = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); + let active_permission_profile = + thread_response_active_permission_profile(active_permission_profile); + let session_id = conversation.session_configured().session_id.to_string(); + thread.session_id = session_id; + + let response = ThreadResumeResponse { + thread, + model, + model_provider: model_provider_id, + service_tier, + cwd, + instruction_sources, + approval_policy: approval_policy.into(), + approvals_reviewer: approvals_reviewer.into(), + sandbox, + permission_profile: Some(permission_profile.into()), + active_permission_profile, + reasoning_effort, + }; + outgoing.send_response(request_id, response).await; + // Match cold resume: metadata-only resume should attach the listener without + // paying the cost of turn reconstruction for historical usage replay. + if let Some(token_usage_thread) = token_usage_thread { + let token_usage_turn_id = latest_token_usage_turn_id_from_rollout_items( + &pending.history_items, + token_usage_thread.turns.as_slice(), + ); + // Rejoining a loaded thread has the same UI contract as a cold resume, but + // uses the live conversation state instead of reconstructing a new session. + send_thread_token_usage_update_to_connection( + outgoing, + connection_id, + conversation_id, + &token_usage_thread, + conversation.as_ref(), + token_usage_turn_id, + ) + .await; + } + if pending.emit_thread_goal_update { + if let Some(state_db) = pending.thread_goal_state_db { + send_thread_goal_snapshot_notification(outgoing, conversation_id, &state_db).await; + } else { + tracing::warn!( + thread_id = %conversation_id, + "state db unavailable when reading thread goal for running thread resume" + ); + } + } + outgoing + .replay_requests_to_connection_for_thread(connection_id, conversation_id) + .await; + // App-server owns resume response and snapshot ordering, so wait until + // replay completes before letting core start goal continuation. + if pending.emit_thread_goal_update + && let Err(err) = conversation.continue_active_goal_if_idle().await + { + tracing::warn!("failed to continue active goal after running-thread resume: {err}"); + } +} + +pub(super) async fn send_thread_goal_snapshot_notification( + outgoing: &Arc, + thread_id: ThreadId, + state_db: &StateDbHandle, +) { + match state_db.get_thread_goal(thread_id).await { + Ok(Some(goal)) => { + outgoing + .send_server_notification(ServerNotification::ThreadGoalUpdated( + ThreadGoalUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: None, + goal: api_thread_goal_from_state(goal), + }, + )) + .await; + } + Ok(None) => { + outgoing + .send_server_notification(ServerNotification::ThreadGoalCleared( + ThreadGoalClearedNotification { + thread_id: thread_id.to_string(), + }, + )) + .await; + } + Err(err) => { + tracing::warn!( + thread_id = %thread_id, + "failed to read thread goal for resume snapshot: {err}" + ); + } + } +} + +pub(crate) fn populate_thread_turns_from_history( + thread: &mut Thread, + items: &[RolloutItem], + active_turn: Option<&Turn>, +) { + let mut turns = build_api_turns_from_rollout_items(items); + if let Some(active_turn) = active_turn { + merge_turn_history_with_active_turn(&mut turns, active_turn.clone()); + } + thread.turns = turns; +} + +pub(super) async fn resolve_pending_server_request( + conversation_id: ThreadId, + thread_state_manager: &ThreadStateManager, + outgoing: &Arc, + request_id: RequestId, +) { + let thread_id = conversation_id.to_string(); + let subscribed_connection_ids = thread_state_manager + .subscribed_connection_ids(conversation_id) + .await; + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing.clone(), + subscribed_connection_ids, + conversation_id, + ); + outgoing + .send_server_notification(ServerNotification::ServerRequestResolved( + ServerRequestResolvedNotification { + thread_id, + request_id, + }, + )) + .await; +} + +pub(super) fn merge_turn_history_with_active_turn(turns: &mut Vec, active_turn: Turn) { + turns.retain(|turn| turn.id != active_turn.id); + turns.push(active_turn); +} + +pub(super) fn set_thread_status_and_interrupt_stale_turns( + thread: &mut Thread, + loaded_status: ThreadStatus, + has_live_in_progress_turn: bool, +) { + let status = resolve_thread_status(loaded_status, has_live_in_progress_turn); + if !matches!(status, ThreadStatus::Active { .. }) { + for turn in &mut thread.turns { + if matches!(turn.status, TurnStatus::InProgress) { + turn.status = TurnStatus::Interrupted; + } + } + } + thread.status = status; +} diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs new file mode 100644 index 0000000000..46fb8ed80d --- /dev/null +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -0,0 +1,3975 @@ +use super::*; +use crate::error_code::method_not_found; + +const THREAD_LIST_DEFAULT_LIMIT: usize = 25; +const THREAD_LIST_MAX_LIMIT: usize = 100; +const PERSIST_EXTENDED_HISTORY_DEPRECATION_SUMMARY: &str = + "persistExtendedHistory is deprecated and ignored"; +const PERSIST_EXTENDED_HISTORY_DEPRECATION_DETAILS: &str = + "Remove this parameter. App-server always uses limited history persistence."; + +struct ThreadListFilters { + model_providers: Option>, + source_kinds: Option>, + archived: bool, + cwd_filters: Option>, + search_term: Option, + use_state_db_only: bool, +} + +fn collect_resume_override_mismatches( + request: &ThreadResumeParams, + config_snapshot: &ThreadConfigSnapshot, +) -> Vec { + let mut mismatch_details = Vec::new(); + + if let Some(requested_model) = request.model.as_deref() + && requested_model != config_snapshot.model + { + mismatch_details.push(format!( + "model requested={requested_model} active={}", + config_snapshot.model + )); + } + if let Some(requested_provider) = request.model_provider.as_deref() + && requested_provider != config_snapshot.model_provider_id + { + mismatch_details.push(format!( + "model_provider requested={requested_provider} active={}", + config_snapshot.model_provider_id + )); + } + if let Some(requested_service_tier) = request.service_tier.as_ref() + && requested_service_tier != &config_snapshot.service_tier + { + mismatch_details.push(format!( + "service_tier requested={requested_service_tier:?} active={:?}", + config_snapshot.service_tier + )); + } + if let Some(requested_cwd) = request.cwd.as_deref() { + let requested_cwd_path = std::path::PathBuf::from(requested_cwd); + if requested_cwd_path != config_snapshot.cwd.as_path() { + mismatch_details.push(format!( + "cwd requested={} active={}", + requested_cwd_path.display(), + config_snapshot.cwd.display() + )); + } + } + if let Some(requested_approval) = request.approval_policy.as_ref() { + let active_approval: AskForApproval = config_snapshot.approval_policy.into(); + if requested_approval != &active_approval { + mismatch_details.push(format!( + "approval_policy requested={requested_approval:?} active={active_approval:?}" + )); + } + } + if let Some(requested_review_policy) = request.approvals_reviewer.as_ref() { + let active_review_policy: codex_app_server_protocol::ApprovalsReviewer = + config_snapshot.approvals_reviewer.into(); + if requested_review_policy != &active_review_policy { + mismatch_details.push(format!( + "approvals_reviewer requested={requested_review_policy:?} active={active_review_policy:?}" + )); + } + } + if let Some(requested_sandbox) = request.sandbox.as_ref() { + let active_sandbox = config_snapshot.sandbox_policy(); + let sandbox_matches = matches!( + (requested_sandbox, &active_sandbox), + ( + SandboxMode::ReadOnly, + codex_protocol::protocol::SandboxPolicy::ReadOnly { .. } + ) | ( + SandboxMode::WorkspaceWrite, + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } + ) | ( + SandboxMode::DangerFullAccess, + codex_protocol::protocol::SandboxPolicy::DangerFullAccess + ) | ( + SandboxMode::DangerFullAccess, + codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. } + ) + ); + if !sandbox_matches { + mismatch_details.push(format!( + "sandbox requested={requested_sandbox:?} active={active_sandbox:?}" + )); + } + } + if request.permissions.is_some() { + mismatch_details.push(format!( + "permissions override was provided and ignored while running; active={:?}", + config_snapshot.active_permission_profile + )); + } + if let Some(requested_personality) = request.personality.as_ref() + && config_snapshot.personality.as_ref() != Some(requested_personality) + { + mismatch_details.push(format!( + "personality requested={requested_personality:?} active={:?}", + config_snapshot.personality + )); + } + + if request.config.is_some() { + mismatch_details + .push("config overrides were provided and ignored while running".to_string()); + } + if request.base_instructions.is_some() { + mismatch_details + .push("baseInstructions override was provided and ignored while running".to_string()); + } + if request.developer_instructions.is_some() { + mismatch_details.push( + "developerInstructions override was provided and ignored while running".to_string(), + ); + } + mismatch_details +} + +fn merge_persisted_resume_metadata( + request_overrides: &mut Option>, + typesafe_overrides: &mut ConfigOverrides, + persisted_metadata: &ThreadMetadata, +) { + if has_model_resume_override(request_overrides.as_ref(), typesafe_overrides) { + return; + } + + typesafe_overrides.model = persisted_metadata.model.clone(); + typesafe_overrides.model_provider = Some(persisted_metadata.model_provider.clone()); + + if let Some(reasoning_effort) = persisted_metadata.reasoning_effort { + request_overrides.get_or_insert_with(HashMap::new).insert( + "model_reasoning_effort".to_string(), + serde_json::Value::String(reasoning_effort.to_string()), + ); + } +} + +fn normalize_thread_list_cwd_filters( + cwd: Option, +) -> Result>, JSONRPCErrorError> { + let Some(cwd) = cwd else { + return Ok(None); + }; + + let cwds = match cwd { + ThreadListCwdFilter::One(cwd) => vec![cwd], + ThreadListCwdFilter::Many(cwds) => cwds, + }; + let mut normalized_cwds = Vec::with_capacity(cwds.len()); + for cwd in cwds { + let cwd = AbsolutePathBuf::relative_to_current_dir(cwd.as_str()) + .map(AbsolutePathBuf::into_path_buf) + .map_err(|err| { + invalid_params(format!("invalid thread/list cwd filter `{cwd}`: {err}")) + })?; + normalized_cwds.push(cwd); + } + + Ok(Some(normalized_cwds)) +} + +fn has_model_resume_override( + request_overrides: Option<&HashMap>, + typesafe_overrides: &ConfigOverrides, +) -> bool { + typesafe_overrides.model.is_some() + || typesafe_overrides.model_provider.is_some() + || request_overrides.is_some_and(|overrides| overrides.contains_key("model")) + || request_overrides + .is_some_and(|overrides| overrides.contains_key("model_reasoning_effort")) +} + +fn validate_dynamic_tools(tools: &[ApiDynamicToolSpec]) -> Result<(), String> { + const DYNAMIC_TOOL_NAME_MAX_LEN: usize = 128; + const DYNAMIC_TOOL_NAMESPACE_MAX_LEN: usize = 64; + const DYNAMIC_TOOL_IDENTIFIER_PATTERN: &str = "^[a-zA-Z0-9_-]+$"; + const RESERVED_RESPONSES_NAMESPACES: &[&str] = &[ + "api_tool", + "browser", + "computer", + "container", + "file_search", + "functions", + "image_gen", + "multi_tool_use", + "python", + "python_user_visible", + "submodel_delegator", + "terminal", + "tool_search", + "web", + ]; + + fn escape_identifier_for_error(value: &str) -> String { + value.escape_default().to_string() + } + + fn validate_dynamic_tool_identifier( + value: &str, + label: &str, + max_len: usize, + ) -> Result<(), String> { + if !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(format!( + "{label} must match {DYNAMIC_TOOL_IDENTIFIER_PATTERN} to match Responses API: {}", + escape_identifier_for_error(value), + )); + } + if value.chars().count() > max_len { + return Err(format!( + "{label} must be at most {max_len} characters to match Responses API: {}", + escape_identifier_for_error(value), + )); + } + Ok(()) + } + + let mut seen = HashSet::new(); + for tool in tools { + let name = tool.name.trim(); + if name.is_empty() { + return Err("dynamic tool name must not be empty".to_string()); + } + if name != tool.name { + return Err(format!( + "dynamic tool name has leading/trailing whitespace: {}", + escape_identifier_for_error(&tool.name), + )); + } + validate_dynamic_tool_identifier(name, "dynamic tool name", DYNAMIC_TOOL_NAME_MAX_LEN)?; + if name == "mcp" || name.starts_with("mcp__") { + return Err(format!("dynamic tool name is reserved: {name}")); + } + let namespace = tool.namespace.as_deref().map(str::trim); + if let Some(namespace) = namespace { + if namespace.is_empty() { + return Err(format!( + "dynamic tool namespace must not be empty for {name}" + )); + } + if Some(namespace) != tool.namespace.as_deref() { + return Err(format!( + "dynamic tool namespace has leading/trailing whitespace for {name}: {namespace}", + name = escape_identifier_for_error(name), + namespace = escape_identifier_for_error(namespace), + )); + } + validate_dynamic_tool_identifier( + namespace, + "dynamic tool namespace", + DYNAMIC_TOOL_NAMESPACE_MAX_LEN, + )?; + if namespace == "mcp" || namespace.starts_with("mcp__") { + return Err(format!( + "dynamic tool namespace is reserved for {name}: {namespace}" + )); + } + if RESERVED_RESPONSES_NAMESPACES.contains(&namespace) { + return Err(format!( + "dynamic tool namespace collides with a reserved Responses API namespace for {name}: {namespace}", + )); + } + } + if !seen.insert((namespace, name)) { + if let Some(namespace) = namespace { + return Err(format!( + "duplicate dynamic tool name in namespace {namespace}: {name}" + )); + } + return Err(format!("duplicate dynamic tool name: {name}")); + } + if tool.defer_loading && namespace.is_none() { + return Err(format!( + "deferred dynamic tool must include a namespace: {name}" + )); + } + + if let Err(err) = codex_tools::parse_tool_input_schema(&tool.input_schema) { + return Err(format!( + "dynamic tool input schema is not supported for {name}: {err}" + )); + } + } + Ok(()) +} + +#[derive(Clone)] +pub(crate) struct ThreadRequestProcessor { + pub(super) auth_manager: Arc, + pub(super) thread_manager: Arc, + pub(super) outgoing: Arc, + pub(super) arg0_paths: Arg0DispatchPaths, + pub(super) config: Arc, + pub(super) config_manager: ConfigManager, + pub(super) thread_store: Arc, + pub(super) pending_thread_unloads: Arc>>, + pub(super) thread_state_manager: ThreadStateManager, + pub(super) thread_watch_manager: ThreadWatchManager, + pub(super) thread_list_state_permit: Arc, + pub(super) thread_goal_processor: ThreadGoalRequestProcessor, + pub(super) state_db: Option, + pub(super) background_tasks: TaskTracker, + pub(super) skills_watcher: Arc, +} + +impl ThreadRequestProcessor { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + auth_manager: Arc, + thread_manager: Arc, + outgoing: Arc, + arg0_paths: Arg0DispatchPaths, + config: Arc, + config_manager: ConfigManager, + thread_store: Arc, + pending_thread_unloads: Arc>>, + thread_state_manager: ThreadStateManager, + thread_watch_manager: ThreadWatchManager, + thread_list_state_permit: Arc, + thread_goal_processor: ThreadGoalRequestProcessor, + state_db: Option, + skills_watcher: Arc, + ) -> Self { + Self { + auth_manager, + thread_manager, + outgoing, + arg0_paths, + config, + config_manager, + thread_store, + pending_thread_unloads, + thread_state_manager, + thread_watch_manager, + thread_list_state_permit, + thread_goal_processor, + state_db, + background_tasks: TaskTracker::new(), + skills_watcher, + } + } + + pub(crate) async fn thread_start( + &self, + request_id: ConnectionRequestId, + params: ThreadStartParams, + app_server_client_name: Option, + app_server_client_version: Option, + request_context: RequestContext, + ) -> Result, JSONRPCErrorError> { + self.thread_start_inner( + request_id, + params, + app_server_client_name, + app_server_client_version, + request_context, + ) + .await + .map(|()| None) + } + + pub(crate) async fn thread_unsubscribe( + &self, + request_id: &ConnectionRequestId, + params: ThreadUnsubscribeParams, + ) -> Result, JSONRPCErrorError> { + self.thread_unsubscribe_response_inner(params, request_id.connection_id) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_resume( + &self, + request_id: ConnectionRequestId, + params: ThreadResumeParams, + app_server_client_name: Option, + app_server_client_version: Option, + ) -> Result, JSONRPCErrorError> { + self.thread_resume_inner( + request_id, + params, + app_server_client_name, + app_server_client_version, + ) + .await + .map(|()| None) + } + + pub(crate) async fn thread_fork( + &self, + request_id: ConnectionRequestId, + params: ThreadForkParams, + app_server_client_name: Option, + app_server_client_version: Option, + ) -> Result, JSONRPCErrorError> { + self.thread_fork_inner( + request_id, + params, + app_server_client_name, + app_server_client_version, + ) + .await + .map(|()| None) + } + + pub(crate) async fn thread_archive( + &self, + request_id: ConnectionRequestId, + params: ThreadArchiveParams, + ) -> Result, JSONRPCErrorError> { + match self.thread_archive_inner(params).await { + Ok((response, archived_thread_ids)) => { + self.outgoing + .send_response(request_id.clone(), response) + .await; + for thread_id in archived_thread_ids { + self.outgoing + .send_server_notification(ServerNotification::ThreadArchived( + ThreadArchivedNotification { thread_id }, + )) + .await; + } + Ok(None) + } + Err(error) => Err(error), + } + } + + pub(crate) async fn thread_increment_elicitation( + &self, + params: ThreadIncrementElicitationParams, + ) -> Result, JSONRPCErrorError> { + self.thread_increment_elicitation_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_decrement_elicitation( + &self, + params: ThreadDecrementElicitationParams, + ) -> Result, JSONRPCErrorError> { + self.thread_decrement_elicitation_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_set_name( + &self, + request_id: ConnectionRequestId, + params: ThreadSetNameParams, + ) -> Result, JSONRPCErrorError> { + match self.thread_set_name_response_inner(params).await { + Ok((response, notification)) => { + self.outgoing + .send_response(request_id.clone(), response) + .await; + if let Some(notification) = notification { + self.outgoing + .send_server_notification(ServerNotification::ThreadNameUpdated( + notification, + )) + .await; + } + Ok(None) + } + Err(error) => Err(error), + } + } + + pub(crate) async fn thread_metadata_update( + &self, + params: ThreadMetadataUpdateParams, + ) -> Result, JSONRPCErrorError> { + self.thread_metadata_update_response_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_memory_mode_set( + &self, + params: ThreadMemoryModeSetParams, + ) -> Result, JSONRPCErrorError> { + self.thread_memory_mode_set_response_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn memory_reset( + &self, + ) -> Result, JSONRPCErrorError> { + self.memory_reset_response_inner() + .await + .map(|response: MemoryResetResponse| Some(response.into())) + } + + pub(crate) async fn thread_unarchive( + &self, + request_id: ConnectionRequestId, + params: ThreadUnarchiveParams, + ) -> Result, JSONRPCErrorError> { + match self.thread_unarchive_inner(params).await { + Ok((response, notification)) => { + self.outgoing + .send_response(request_id.clone(), response) + .await; + self.outgoing + .send_server_notification(ServerNotification::ThreadUnarchived(notification)) + .await; + Ok(None) + } + Err(error) => Err(error), + } + } + + pub(crate) async fn thread_compact_start( + &self, + request_id: &ConnectionRequestId, + params: ThreadCompactStartParams, + ) -> Result, JSONRPCErrorError> { + self.thread_compact_start_inner(request_id, params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_background_terminals_clean( + &self, + request_id: &ConnectionRequestId, + params: ThreadBackgroundTerminalsCleanParams, + ) -> Result, JSONRPCErrorError> { + self.thread_background_terminals_clean_inner(request_id, params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_rollback( + &self, + request_id: &ConnectionRequestId, + params: ThreadRollbackParams, + ) -> Result, JSONRPCErrorError> { + self.thread_rollback_inner(request_id, params) + .await + .map(|()| None) + } + + pub(crate) async fn thread_list( + &self, + params: ThreadListParams, + ) -> Result, JSONRPCErrorError> { + self.thread_list_response_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_loaded_list( + &self, + params: ThreadLoadedListParams, + ) -> Result, JSONRPCErrorError> { + self.thread_loaded_list_response_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_read( + &self, + params: ThreadReadParams, + ) -> Result, JSONRPCErrorError> { + self.thread_read_response_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_turns_list( + &self, + params: ThreadTurnsListParams, + ) -> Result, JSONRPCErrorError> { + self.thread_turns_list_response_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_turns_items_list( + &self, + _params: ThreadTurnsItemsListParams, + ) -> Result, JSONRPCErrorError> { + Err(method_not_found( + "thread/turns/items/list is not supported yet", + )) + } + + pub(crate) async fn thread_shell_command( + &self, + request_id: &ConnectionRequestId, + params: ThreadShellCommandParams, + ) -> Result, JSONRPCErrorError> { + self.thread_shell_command_inner(request_id, params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_approve_guardian_denied_action( + &self, + request_id: &ConnectionRequestId, + params: ThreadApproveGuardianDeniedActionParams, + ) -> Result, JSONRPCErrorError> { + self.thread_approve_guardian_denied_action_inner(request_id, params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn conversation_summary( + &self, + params: GetConversationSummaryParams, + ) -> Result, JSONRPCErrorError> { + self.get_thread_summary_response_inner(params) + .await + .map(|response| Some(response.into())) + } + + async fn instruction_sources_from_config(config: &Config) -> Vec { + codex_core::AgentsMdManager::new(config) + .instruction_sources(LOCAL_FS.as_ref()) + .await + } + + async fn load_thread( + &self, + thread_id: &str, + ) -> Result<(ThreadId, Arc), JSONRPCErrorError> { + // Resolve the core conversation handle from a v2 thread id string. + let thread_id = ThreadId::from_string(thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + + let thread = self + .thread_manager + .get_thread(thread_id) + .await + .map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?; + + Ok((thread_id, thread)) + } + async fn acquire_thread_list_state_permit( + &self, + ) -> Result, JSONRPCErrorError> { + self.thread_list_state_permit + .acquire() + .await + .map_err(|err| { + internal_error(format!("failed to acquire thread list state permit: {err}")) + }) + } + + async fn set_app_server_client_info( + thread: &CodexThread, + app_server_client_name: Option, + app_server_client_version: Option, + ) -> Result<(), JSONRPCErrorError> { + let mcp_elicitations_auto_deny = xcode_26_4_mcp_elicitations_auto_deny( + app_server_client_name.as_deref(), + app_server_client_version.as_deref(), + ); + thread + .set_app_server_client_info( + app_server_client_name, + app_server_client_version, + mcp_elicitations_auto_deny, + ) + .await + .map_err(|err| internal_error(format!("failed to set app server client info: {err}"))) + } + + async fn finalize_thread_teardown(&self, thread_id: ThreadId) { + self.pending_thread_unloads.lock().await.remove(&thread_id); + self.outgoing + .cancel_requests_for_thread(thread_id, /*error*/ None) + .await; + self.thread_state_manager + .remove_thread_state(thread_id) + .await; + self.thread_watch_manager + .remove_thread(&thread_id.to_string()) + .await; + } + + async fn thread_unsubscribe_response_inner( + &self, + params: ThreadUnsubscribeParams, + connection_id: ConnectionId, + ) -> Result { + let thread_id = ThreadId::from_string(¶ms.thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + + if self.thread_manager.get_thread(thread_id).await.is_err() { + self.finalize_thread_teardown(thread_id).await; + return Ok(ThreadUnsubscribeResponse { + status: ThreadUnsubscribeStatus::NotLoaded, + }); + }; + + let was_subscribed = self + .thread_state_manager + .unsubscribe_connection_from_thread(thread_id, connection_id) + .await; + + let status = if was_subscribed { + ThreadUnsubscribeStatus::Unsubscribed + } else { + ThreadUnsubscribeStatus::NotSubscribed + }; + Ok(ThreadUnsubscribeResponse { status }) + } + + async fn prepare_thread_for_archive(&self, thread_id: ThreadId) { + let removed_conversation = self.thread_manager.remove_thread(&thread_id).await; + if let Some(conversation) = removed_conversation { + info!("thread {thread_id} was active; shutting down"); + match wait_for_thread_shutdown(&conversation).await { + ThreadShutdownResult::Complete => {} + ThreadShutdownResult::SubmitFailed => { + error!( + "failed to submit Shutdown to thread {thread_id}; proceeding with archive" + ); + } + ThreadShutdownResult::TimedOut => { + warn!("thread {thread_id} shutdown timed out; proceeding with archive"); + } + } + } + self.finalize_thread_teardown(thread_id).await; + } + + fn listener_task_context(&self) -> ListenerTaskContext { + ListenerTaskContext { + thread_manager: Arc::clone(&self.thread_manager), + thread_state_manager: self.thread_state_manager.clone(), + outgoing: Arc::clone(&self.outgoing), + pending_thread_unloads: Arc::clone(&self.pending_thread_unloads), + thread_watch_manager: self.thread_watch_manager.clone(), + thread_list_state_permit: self.thread_list_state_permit.clone(), + fallback_model_provider: self.config.model_provider_id.clone(), + codex_home: self.config.codex_home.to_path_buf(), + skills_watcher: Arc::clone(&self.skills_watcher), + } + } + + async fn ensure_conversation_listener( + &self, + conversation_id: ThreadId, + connection_id: ConnectionId, + raw_events_enabled: bool, + ) -> Result { + super::thread_lifecycle::ensure_conversation_listener( + self.listener_task_context(), + conversation_id, + connection_id, + raw_events_enabled, + ) + .await + } + + async fn ensure_listener_task_running( + &self, + conversation_id: ThreadId, + conversation: Arc, + thread_state: Arc>, + ) -> Result<(), JSONRPCErrorError> { + super::thread_lifecycle::ensure_listener_task_running( + self.listener_task_context(), + conversation_id, + conversation, + thread_state, + ) + .await + } + + async fn thread_start_inner( + &self, + request_id: ConnectionRequestId, + params: ThreadStartParams, + app_server_client_name: Option, + app_server_client_version: Option, + request_context: RequestContext, + ) -> Result<(), JSONRPCErrorError> { + let ThreadStartParams { + model, + model_provider, + service_tier, + cwd, + approval_policy, + approvals_reviewer, + sandbox, + permissions, + config, + service_name, + base_instructions, + developer_instructions, + dynamic_tools, + mock_experimental_field: _mock_experimental_field, + experimental_raw_events, + personality, + ephemeral, + session_start_source, + thread_source, + environments, + persist_extended_history, + } = params; + if sandbox.is_some() && permissions.is_some() { + return Err(invalid_request( + "`permissions` cannot be combined with `sandbox`", + )); + } + if persist_extended_history { + self.send_persist_extended_history_deprecation_notice(request_id.connection_id) + .await; + } + let environment_selections = self.parse_environment_selections(environments)?; + let mut typesafe_overrides = self.build_thread_config_overrides( + model, + model_provider, + service_tier, + cwd, + approval_policy, + approvals_reviewer, + sandbox, + permissions, + base_instructions, + developer_instructions, + personality, + ); + typesafe_overrides.ephemeral = ephemeral; + let listener_task_context = ListenerTaskContext { + thread_manager: Arc::clone(&self.thread_manager), + thread_state_manager: self.thread_state_manager.clone(), + outgoing: Arc::clone(&self.outgoing), + pending_thread_unloads: Arc::clone(&self.pending_thread_unloads), + thread_watch_manager: self.thread_watch_manager.clone(), + thread_list_state_permit: self.thread_list_state_permit.clone(), + fallback_model_provider: self.config.model_provider_id.clone(), + codex_home: self.config.codex_home.to_path_buf(), + skills_watcher: Arc::clone(&self.skills_watcher), + }; + let request_trace = request_context.request_trace(); + let config_manager = self.config_manager.clone(); + let outgoing = Arc::clone(&listener_task_context.outgoing); + let error_request_id = request_id.clone(); + let thread_start_task = async move { + if let Err(error) = Self::thread_start_task( + listener_task_context, + config_manager, + request_id, + app_server_client_name, + app_server_client_version, + config, + typesafe_overrides, + dynamic_tools, + session_start_source, + thread_source.map(Into::into), + environment_selections, + service_name, + experimental_raw_events, + request_trace, + ) + .await + { + outgoing.send_error(error_request_id, error).await; + } + }; + self.background_tasks + .spawn(thread_start_task.instrument(request_context.span())); + Ok(()) + } + + pub(crate) async fn drain_background_tasks(&self) { + self.background_tasks.close(); + if tokio::time::timeout(Duration::from_secs(10), self.background_tasks.wait()) + .await + .is_err() + { + warn!("timed out waiting for background tasks to shut down; proceeding"); + } + } + + pub(crate) async fn clear_all_thread_listeners(&self) { + self.thread_state_manager.clear_all_listeners().await; + } + + pub(crate) async fn shutdown_threads(&self) { + let report = self + .thread_manager + .shutdown_all_threads_bounded(Duration::from_secs(10)) + .await; + for thread_id in report.submit_failed { + warn!("failed to submit Shutdown to thread {thread_id}"); + } + for thread_id in report.timed_out { + warn!("timed out waiting for thread {thread_id} to shut down"); + } + } + + async fn request_trace_context( + &self, + request_id: &ConnectionRequestId, + ) -> Option { + self.outgoing.request_trace_context(request_id).await + } + + async fn send_persist_extended_history_deprecation_notice(&self, connection_id: ConnectionId) { + self.outgoing + .send_server_notification_to_connections( + &[connection_id], + ServerNotification::DeprecationNotice(DeprecationNoticeNotification { + summary: PERSIST_EXTENDED_HISTORY_DEPRECATION_SUMMARY.to_string(), + details: Some(PERSIST_EXTENDED_HISTORY_DEPRECATION_DETAILS.to_string()), + }), + ) + .await; + } + + async fn submit_core_op( + &self, + request_id: &ConnectionRequestId, + thread: &CodexThread, + op: Op, + ) -> CodexResult { + thread + .submit_with_trace(op, self.request_trace_context(request_id).await) + .await + } + + #[allow(clippy::too_many_arguments)] + async fn thread_start_task( + listener_task_context: ListenerTaskContext, + config_manager: ConfigManager, + request_id: ConnectionRequestId, + app_server_client_name: Option, + app_server_client_version: Option, + config_overrides: Option>, + typesafe_overrides: ConfigOverrides, + dynamic_tools: Option>, + session_start_source: Option, + thread_source: Option, + environments: Option>, + service_name: Option, + experimental_raw_events: bool, + request_trace: Option, + ) -> Result<(), JSONRPCErrorError> { + let thread_start_started_at = std::time::Instant::now(); + let requested_cwd = typesafe_overrides.cwd.clone(); + let mut config = config_manager + .load_with_overrides(config_overrides.clone(), typesafe_overrides.clone()) + .await + .map_err(|err| config_load_error(&err))?; + + // The user may have requested WorkspaceWrite or DangerFullAccess via + // the command line, though in the process of deriving the Config, it + // could be downgraded to ReadOnly (perhaps there is no sandbox + // available on Windows or the enterprise config disallows it). The cwd + // should still be considered "trusted" in this case. + let requested_permissions_trust_project = + requested_permissions_trust_project(&typesafe_overrides, config.cwd.as_path()); + let effective_permissions_trust_project = permission_profile_trusts_project( + &config.permissions.permission_profile(), + config.cwd.as_path(), + ); + + if requested_cwd.is_some() + && config.active_project.trust_level.is_none() + && (requested_permissions_trust_project || effective_permissions_trust_project) + { + let trust_target = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &config.cwd) + .await + .unwrap_or_else(|| config.cwd.clone()); + let current_cli_overrides = config_manager.current_cli_overrides(); + let cli_overrides_with_trust; + let cli_overrides_for_reload = if let Err(err) = + codex_core::config::set_project_trust_level( + &listener_task_context.codex_home, + trust_target.as_path(), + TrustLevel::Trusted, + ) { + warn!( + "failed to persist trusted project state for {}; continuing with in-memory trust for this thread: {err}", + trust_target.display() + ); + let mut project = toml::map::Map::new(); + project.insert( + "trust_level".to_string(), + TomlValue::String("trusted".to_string()), + ); + let mut projects = toml::map::Map::new(); + projects.insert( + project_trust_key(trust_target.as_path()), + TomlValue::Table(project), + ); + cli_overrides_with_trust = current_cli_overrides + .iter() + .cloned() + .chain(std::iter::once(( + "projects".to_string(), + TomlValue::Table(projects), + ))) + .collect::>(); + cli_overrides_with_trust.as_slice() + } else { + current_cli_overrides.as_slice() + }; + + config = config_manager + .load_with_cli_overrides( + cli_overrides_for_reload, + config_overrides, + typesafe_overrides, + /*fallback_cwd*/ None, + ) + .await + .map_err(|err| config_load_error(&err))?; + } + + let instruction_sources = Self::instruction_sources_from_config(&config).await; + let environments = environments.unwrap_or_else(|| { + listener_task_context + .thread_manager + .default_environment_selections(&config.cwd) + }); + let dynamic_tools = dynamic_tools.unwrap_or_default(); + let core_dynamic_tools = if dynamic_tools.is_empty() { + Vec::new() + } else { + validate_dynamic_tools(&dynamic_tools).map_err(invalid_request)?; + dynamic_tools + .into_iter() + .map(|tool| CoreDynamicToolSpec { + namespace: tool.namespace, + name: tool.name, + description: tool.description, + input_schema: tool.input_schema, + defer_loading: tool.defer_loading, + }) + .collect() + }; + let core_dynamic_tool_count = core_dynamic_tools.len(); + let create_thread_started_at = std::time::Instant::now(); + let NewThread { + thread_id, + thread, + session_configured, + .. + } = listener_task_context + .thread_manager + .start_thread_with_options(StartThreadOptions { + config, + initial_history: match session_start_source + .unwrap_or(codex_app_server_protocol::ThreadStartSource::Startup) + { + codex_app_server_protocol::ThreadStartSource::Startup => InitialHistory::New, + codex_app_server_protocol::ThreadStartSource::Clear => InitialHistory::Cleared, + }, + session_source: None, + thread_source, + dynamic_tools: core_dynamic_tools, + persist_extended_history: false, + metrics_service_name: service_name, + parent_trace: request_trace, + environments, + }) + .instrument(tracing::info_span!( + "app_server.thread_start.create_thread", + otel.name = "app_server.thread_start.create_thread", + thread_start.dynamic_tool_count = core_dynamic_tool_count, + thread_start.persist_extended_history = false, + )) + .await + .map_err(|err| match err { + CodexErr::InvalidRequest(message) => invalid_request(message), + err => internal_error(format!("error creating thread: {err}")), + })?; + let session_telemetry = thread.session_telemetry(); + session_telemetry.record_startup_phase( + "thread_start_create_thread", + create_thread_started_at.elapsed(), + Some("ready"), + ); + + Self::set_app_server_client_info( + thread.as_ref(), + app_server_client_name, + app_server_client_version, + ) + .await?; + + let config_snapshot = thread + .config_snapshot() + .instrument(tracing::info_span!( + "app_server.thread_start.config_snapshot", + otel.name = "app_server.thread_start.config_snapshot", + )) + .await; + let mut thread = build_thread_from_snapshot( + thread_id, + session_configured.session_id.to_string(), + &config_snapshot, + session_configured.rollout_path.clone(), + ); + + // Auto-attach a thread listener when starting a thread. + log_listener_attach_result( + super::thread_lifecycle::ensure_conversation_listener( + listener_task_context.clone(), + thread_id, + request_id.connection_id, + experimental_raw_events, + ) + .instrument(tracing::info_span!( + "app_server.thread_start.attach_listener", + otel.name = "app_server.thread_start.attach_listener", + thread_start.experimental_raw_events = experimental_raw_events, + )) + .await, + thread_id, + request_id.connection_id, + "thread", + ); + + listener_task_context + .thread_watch_manager + .upsert_thread_silently(thread.clone()) + .instrument(tracing::info_span!( + "app_server.thread_start.upsert_thread", + otel.name = "app_server.thread_start.upsert_thread", + )) + .await; + + thread.status = resolve_thread_status( + listener_task_context + .thread_watch_manager + .loaded_status_for_thread(&thread.id) + .instrument(tracing::info_span!( + "app_server.thread_start.resolve_status", + otel.name = "app_server.thread_start.resolve_status", + )) + .await, + /*has_in_progress_turn*/ false, + ); + + let sandbox = thread_response_sandbox_policy( + &config_snapshot.permission_profile, + config_snapshot.cwd.as_path(), + ); + let active_permission_profile = + thread_response_active_permission_profile(config_snapshot.active_permission_profile); + + let response = ThreadStartResponse { + thread: thread.clone(), + model: config_snapshot.model, + model_provider: config_snapshot.model_provider_id, + service_tier: config_snapshot.service_tier, + cwd: config_snapshot.cwd, + instruction_sources, + approval_policy: config_snapshot.approval_policy.into(), + approvals_reviewer: config_snapshot.approvals_reviewer.into(), + sandbox, + permission_profile: Some(config_snapshot.permission_profile.into()), + active_permission_profile, + reasoning_effort: config_snapshot.reasoning_effort, + }; + let notif = thread_started_notification(thread); + listener_task_context + .outgoing + .send_response(request_id, response) + .instrument(tracing::info_span!( + "app_server.thread_start.send_response", + otel.name = "app_server.thread_start.send_response", + )) + .await; + + listener_task_context + .outgoing + .send_server_notification(ServerNotification::ThreadStarted(notif)) + .instrument(tracing::info_span!( + "app_server.thread_start.notify_started", + otel.name = "app_server.thread_start.notify_started", + )) + .await; + session_telemetry.record_startup_phase( + "thread_start_total", + thread_start_started_at.elapsed(), + Some("ready"), + ); + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + fn build_thread_config_overrides( + &self, + model: Option, + model_provider: Option, + service_tier: Option>, + cwd: Option, + approval_policy: Option, + approvals_reviewer: Option, + sandbox: Option, + permissions: Option, + base_instructions: Option, + developer_instructions: Option, + personality: Option, + ) -> ConfigOverrides { + let mut overrides = ConfigOverrides { + model, + model_provider, + service_tier, + cwd: cwd.map(PathBuf::from), + approval_policy: approval_policy + .map(codex_app_server_protocol::AskForApproval::to_core), + approvals_reviewer: approvals_reviewer + .map(codex_app_server_protocol::ApprovalsReviewer::to_core), + sandbox_mode: sandbox.map(SandboxMode::to_core), + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), + base_instructions, + developer_instructions, + personality, + ..Default::default() + }; + apply_permission_profile_selection_to_config_overrides(&mut overrides, permissions); + overrides + } + + fn parse_environment_selections( + &self, + environments: Option>, + ) -> Result>, JSONRPCErrorError> { + let environment_selections = environments.map(|environments| { + environments + .into_iter() + .map(|environment| TurnEnvironmentSelection { + environment_id: environment.environment_id, + cwd: environment.cwd, + }) + .collect::>() + }); + if let Some(environment_selections) = environment_selections.as_ref() { + self.thread_manager + .validate_environment_selections(environment_selections) + .map_err(|err| invalid_request(environment_selection_error_message(err)))?; + } + Ok(environment_selections) + } + + async fn thread_archive_inner( + &self, + params: ThreadArchiveParams, + ) -> Result<(ThreadArchiveResponse, Vec), JSONRPCErrorError> { + let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?; + self.thread_archive_response(params).await + } + + async fn thread_archive_response( + &self, + params: ThreadArchiveParams, + ) -> Result<(ThreadArchiveResponse, Vec), JSONRPCErrorError> { + let thread_id = ThreadId::from_string(¶ms.thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + + let mut thread_ids = vec![thread_id]; + if let Some(state_db_ctx) = self.state_db.as_ref() { + let descendants = state_db_ctx + .list_thread_spawn_descendants(thread_id) + .await + .map_err(|err| { + internal_error(format!( + "failed to list spawned descendants for thread id {thread_id}: {err}" + )) + })?; + let mut seen = HashSet::from([thread_id]); + for descendant_id in descendants { + if seen.insert(descendant_id) { + thread_ids.push(descendant_id); + } + } + } + + let mut archive_thread_ids = Vec::new(); + match self + .thread_store + .read_thread(StoreReadThreadParams { + thread_id, + include_archived: false, + include_history: false, + }) + .await + { + Ok(thread) => { + if thread.archived_at.is_none() { + archive_thread_ids.push(thread_id); + } + } + Err(err) => return Err(thread_store_archive_error("archive", err)), + } + for descendant_thread_id in thread_ids.into_iter().skip(1) { + match self + .thread_store + .read_thread(StoreReadThreadParams { + thread_id: descendant_thread_id, + include_archived: true, + include_history: false, + }) + .await + { + Ok(thread) => { + if thread.archived_at.is_none() { + archive_thread_ids.push(descendant_thread_id); + } + } + Err(err) => { + warn!( + "failed to read spawned descendant thread {descendant_thread_id} while archiving {thread_id}: {err}" + ); + } + } + } + + let mut archived_thread_ids = Vec::new(); + let Some((parent_thread_id, descendant_thread_ids)) = archive_thread_ids.split_first() + else { + return Ok((ThreadArchiveResponse {}, archived_thread_ids)); + }; + + self.prepare_thread_for_archive(*parent_thread_id).await; + match self + .thread_store + .archive_thread(StoreArchiveThreadParams { + thread_id: *parent_thread_id, + }) + .await + { + Ok(()) => { + archived_thread_ids.push(parent_thread_id.to_string()); + } + Err(err) => return Err(thread_store_archive_error("archive", err)), + } + + for descendant_thread_id in descendant_thread_ids.iter().rev().copied() { + self.prepare_thread_for_archive(descendant_thread_id).await; + match self + .thread_store + .archive_thread(StoreArchiveThreadParams { + thread_id: descendant_thread_id, + }) + .await + { + Ok(()) => { + archived_thread_ids.push(descendant_thread_id.to_string()); + } + Err(err) => { + warn!( + "failed to archive spawned descendant thread {descendant_thread_id} while archiving {thread_id}: {err}" + ); + } + } + } + + Ok((ThreadArchiveResponse {}, archived_thread_ids)) + } + + async fn thread_increment_elicitation_inner( + &self, + params: ThreadIncrementElicitationParams, + ) -> Result { + let (_, thread) = self.load_thread(¶ms.thread_id).await?; + let count = thread + .increment_out_of_band_elicitation_count() + .await + .map_err(|err| { + internal_error(format!( + "failed to increment out-of-band elicitation counter: {err}" + )) + })?; + Ok(ThreadIncrementElicitationResponse { + count, + paused: count > 0, + }) + } + + async fn thread_decrement_elicitation_inner( + &self, + params: ThreadDecrementElicitationParams, + ) -> Result { + let (_, thread) = self.load_thread(¶ms.thread_id).await?; + let count = thread + .decrement_out_of_band_elicitation_count() + .await + .map_err(|err| match err { + CodexErr::InvalidRequest(message) => invalid_request(message), + err => internal_error(format!( + "failed to decrement out-of-band elicitation counter: {err}" + )), + })?; + Ok(ThreadDecrementElicitationResponse { + count, + paused: count > 0, + }) + } + + async fn thread_set_name_response_inner( + &self, + params: ThreadSetNameParams, + ) -> Result<(ThreadSetNameResponse, Option), JSONRPCErrorError> + { + let ThreadSetNameParams { thread_id, name } = params; + let thread_id = ThreadId::from_string(&thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + let Some(name) = codex_core::util::normalize_thread_name(&name) else { + return Err(invalid_request("thread name must not be empty")); + }; + + let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?; + self.thread_manager + .update_thread_metadata( + thread_id, + StoreThreadMetadataPatch { + name: Some(Some(name.clone())), + ..Default::default() + }, + /*include_archived*/ false, + ) + .await + .map_err(|err| core_thread_write_error("set thread name", err))?; + + Ok(( + ThreadSetNameResponse {}, + Some(ThreadNameUpdatedNotification { + thread_id: thread_id.to_string(), + thread_name: Some(name), + }), + )) + } + + async fn thread_memory_mode_set_response_inner( + &self, + params: ThreadMemoryModeSetParams, + ) -> Result { + let ThreadMemoryModeSetParams { thread_id, mode } = params; + let thread_id = ThreadId::from_string(&thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + + self.thread_manager + .update_thread_metadata( + thread_id, + StoreThreadMetadataPatch { + memory_mode: Some(mode.to_core()), + ..Default::default() + }, + /*include_archived*/ false, + ) + .await + .map_err(|err| core_thread_write_error("set thread memory mode", err))?; + + Ok(ThreadMemoryModeSetResponse {}) + } + + async fn memory_reset_response_inner(&self) -> Result { + let state_db = self + .state_db + .clone() + .ok_or_else(|| internal_error("sqlite state db unavailable for memory reset"))?; + + state_db.clear_memory_data().await.map_err(|err| { + internal_error(format!("failed to clear memory rows in state db: {err}")) + })?; + + clear_memory_roots_contents(&self.config.codex_home) + .await + .map_err(|err| { + internal_error(format!( + "failed to clear memory directories under {}: {err}", + self.config.codex_home.display() + )) + })?; + + Ok(MemoryResetResponse {}) + } + + async fn thread_metadata_update_response_inner( + &self, + params: ThreadMetadataUpdateParams, + ) -> Result { + let ThreadMetadataUpdateParams { + thread_id, + git_info, + } = params; + + let thread_uuid = ThreadId::from_string(&thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + + let Some(ThreadMetadataGitInfoUpdateParams { + sha, + branch, + origin_url, + }) = git_info + else { + return Err(invalid_request("gitInfo must include at least one field")); + }; + + if sha.is_none() && branch.is_none() && origin_url.is_none() { + return Err(invalid_request("gitInfo must include at least one field")); + } + + let git_sha = Self::normalize_thread_metadata_git_field(sha, "gitInfo.sha")?; + let git_branch = Self::normalize_thread_metadata_git_field(branch, "gitInfo.branch")?; + let git_origin_url = + Self::normalize_thread_metadata_git_field(origin_url, "gitInfo.originUrl")?; + + let patch = StoreThreadMetadataPatch { + git_info: Some(StoreGitInfoPatch { + sha: git_sha, + branch: git_branch, + origin_url: git_origin_url, + }), + ..Default::default() + }; + + let updated_thread = { + let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?; + self.thread_manager + .update_thread_metadata(thread_uuid, patch, /*include_archived*/ true) + .await + .map_err(|err| core_thread_write_error("update thread metadata", err))? + }; + let (mut thread, _) = thread_from_stored_thread( + updated_thread, + self.config.model_provider_id.as_str(), + &self.config.cwd, + ); + if let Ok(loaded_thread) = self.thread_manager.get_thread(thread_uuid).await { + thread.session_id = loaded_thread.session_configured().session_id.to_string(); + } + self.attach_thread_name(thread_uuid, &mut thread).await; + thread.status = resolve_thread_status( + self.thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await, + /*has_in_progress_turn*/ false, + ); + + Ok(ThreadMetadataUpdateResponse { thread }) + } + + fn normalize_thread_metadata_git_field( + value: Option>, + name: &str, + ) -> Result>, JSONRPCErrorError> { + match value { + Some(Some(value)) => { + let value = value.trim().to_string(); + if value.is_empty() { + return Err(invalid_request(format!("{name} must not be empty"))); + } + Ok(Some(Some(value))) + } + Some(None) => Ok(Some(None)), + None => Ok(None), + } + } + + async fn thread_unarchive_inner( + &self, + params: ThreadUnarchiveParams, + ) -> Result<(ThreadUnarchiveResponse, ThreadUnarchivedNotification), JSONRPCErrorError> { + let _thread_list_state_permit = self.acquire_thread_list_state_permit().await?; + let (response, thread_id) = self.thread_unarchive_response(params).await?; + Ok((response, ThreadUnarchivedNotification { thread_id })) + } + + async fn thread_unarchive_response( + &self, + params: ThreadUnarchiveParams, + ) -> Result<(ThreadUnarchiveResponse, String), JSONRPCErrorError> { + let thread_id = ThreadId::from_string(¶ms.thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + + let fallback_provider = self.config.model_provider_id.clone(); + let stored_thread = self + .thread_store + .unarchive_thread(StoreArchiveThreadParams { thread_id }) + .await + .map_err(|err| thread_store_archive_error("unarchive", err))?; + let (mut thread, _) = + thread_from_stored_thread(stored_thread, fallback_provider.as_str(), &self.config.cwd); + + thread.status = resolve_thread_status( + self.thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await, + /*has_in_progress_turn*/ false, + ); + self.attach_thread_name(thread_id, &mut thread).await; + let thread_id = thread.id.clone(); + Ok((ThreadUnarchiveResponse { thread }, thread_id)) + } + + async fn thread_rollback_inner( + &self, + request_id: &ConnectionRequestId, + params: ThreadRollbackParams, + ) -> Result<(), JSONRPCErrorError> { + self.thread_rollback_start(request_id, params).await + } + + async fn thread_rollback_start( + &self, + request_id: &ConnectionRequestId, + params: ThreadRollbackParams, + ) -> Result<(), JSONRPCErrorError> { + let ThreadRollbackParams { + thread_id, + num_turns, + } = params; + + if num_turns == 0 { + return Err(invalid_request("numTurns must be >= 1")); + } + + let (thread_id, thread) = self.load_thread(&thread_id).await?; + + let request = request_id.clone(); + + let rollback_already_in_progress = { + let thread_state = self.thread_state_manager.thread_state(thread_id).await; + let mut thread_state = thread_state.lock().await; + if thread_state.pending_rollbacks.is_some() { + true + } else { + thread_state.pending_rollbacks = Some(request.clone()); + false + } + }; + if rollback_already_in_progress { + return Err(invalid_request( + "rollback already in progress for this thread", + )); + } + + if let Err(err) = self + .submit_core_op( + request_id, + thread.as_ref(), + Op::ThreadRollback { num_turns }, + ) + .await + { + // No ThreadRollback event will arrive if an error occurs. + // Clean up and reply immediately. + let thread_state = self.thread_state_manager.thread_state(thread_id).await; + thread_state.lock().await.pending_rollbacks = None; + + return Err(internal_error(format!("failed to start rollback: {err}"))); + } + Ok(()) + } + + async fn thread_compact_start_inner( + &self, + request_id: &ConnectionRequestId, + params: ThreadCompactStartParams, + ) -> Result { + let ThreadCompactStartParams { thread_id } = params; + + let (_, thread) = self.load_thread(&thread_id).await?; + self.submit_core_op(request_id, thread.as_ref(), Op::Compact) + .await + .map_err(|err| internal_error(format!("failed to start compaction: {err}")))?; + Ok(ThreadCompactStartResponse {}) + } + + async fn thread_background_terminals_clean_inner( + &self, + request_id: &ConnectionRequestId, + params: ThreadBackgroundTerminalsCleanParams, + ) -> Result { + let ThreadBackgroundTerminalsCleanParams { thread_id } = params; + + let (_, thread) = self.load_thread(&thread_id).await?; + self.submit_core_op(request_id, thread.as_ref(), Op::CleanBackgroundTerminals) + .await + .map_err(|err| { + internal_error(format!("failed to clean background terminals: {err}")) + })?; + Ok(ThreadBackgroundTerminalsCleanResponse {}) + } + + async fn thread_shell_command_inner( + &self, + request_id: &ConnectionRequestId, + params: ThreadShellCommandParams, + ) -> Result { + let ThreadShellCommandParams { thread_id, command } = params; + let command = command.trim().to_string(); + if command.is_empty() { + return Err(invalid_request("command must not be empty")); + } + + let (_, thread) = self.load_thread(&thread_id).await?; + self.submit_core_op( + request_id, + thread.as_ref(), + Op::RunUserShellCommand { command }, + ) + .await + .map_err(|err| internal_error(format!("failed to start shell command: {err}")))?; + Ok(ThreadShellCommandResponse {}) + } + + async fn thread_approve_guardian_denied_action_inner( + &self, + request_id: &ConnectionRequestId, + params: ThreadApproveGuardianDeniedActionParams, + ) -> Result { + let ThreadApproveGuardianDeniedActionParams { thread_id, event } = params; + let event = serde_json::from_value(event) + .map_err(|err| invalid_request(format!("invalid Guardian denial event: {err}")))?; + let (_, thread) = self.load_thread(&thread_id).await?; + + self.submit_core_op( + request_id, + thread.as_ref(), + Op::ApproveGuardianDeniedAction { event }, + ) + .await + .map_err(|err| internal_error(format!("failed to approve Guardian denial: {err}")))?; + Ok(ThreadApproveGuardianDeniedActionResponse {}) + } + + async fn thread_list_response_inner( + &self, + params: ThreadListParams, + ) -> Result { + let ThreadListParams { + cursor, + limit, + sort_key, + sort_direction, + model_providers, + source_kinds, + archived, + cwd, + use_state_db_only, + search_term, + } = params; + let cwd_filters = normalize_thread_list_cwd_filters(cwd)?; + + let requested_page_size = limit + .map(|value| value as usize) + .unwrap_or(THREAD_LIST_DEFAULT_LIMIT) + .clamp(1, THREAD_LIST_MAX_LIMIT); + let store_sort_key = match sort_key.unwrap_or(ThreadSortKey::CreatedAt) { + ThreadSortKey::CreatedAt => StoreThreadSortKey::CreatedAt, + ThreadSortKey::UpdatedAt => StoreThreadSortKey::UpdatedAt, + }; + let sort_direction = sort_direction.unwrap_or(SortDirection::Desc); + let (stored_threads, next_cursor) = self + .list_threads_common( + requested_page_size, + cursor, + store_sort_key, + sort_direction, + ThreadListFilters { + model_providers, + source_kinds, + archived: archived.unwrap_or(false), + cwd_filters, + search_term, + use_state_db_only, + }, + ) + .await?; + let backwards_cursor = stored_threads.first().and_then(|thread| { + thread_backwards_cursor_for_sort_key(thread, store_sort_key, sort_direction) + }); + let mut threads = Vec::with_capacity(stored_threads.len()); + let mut status_ids = Vec::with_capacity(stored_threads.len()); + let fallback_provider = self.config.model_provider_id.clone(); + + for stored_thread in stored_threads { + let (thread, _) = thread_from_stored_thread( + stored_thread, + fallback_provider.as_str(), + &self.config.cwd, + ); + status_ids.push(thread.id.clone()); + threads.push(thread); + } + + let statuses = self + .thread_watch_manager + .loaded_statuses_for_threads(status_ids) + .await; + + let data: Vec<_> = threads + .into_iter() + .map(|mut thread| { + if let Some(status) = statuses.get(&thread.id) { + thread.status = status.clone(); + } + thread + }) + .collect(); + Ok(ThreadListResponse { + data, + next_cursor, + backwards_cursor, + }) + } + + async fn thread_loaded_list_response_inner( + &self, + params: ThreadLoadedListParams, + ) -> Result { + let ThreadLoadedListParams { cursor, limit } = params; + let mut data: Vec = self + .thread_manager + .list_thread_ids() + .await + .into_iter() + .map(|thread_id| thread_id.to_string()) + .collect(); + + if data.is_empty() { + return Ok(ThreadLoadedListResponse { + data, + next_cursor: None, + }); + } + + data.sort(); + let total = data.len(); + let start = match cursor { + Some(cursor) => { + let cursor = match ThreadId::from_string(&cursor) { + Ok(id) => id.to_string(), + Err(_) => return Err(invalid_request(format!("invalid cursor: {cursor}"))), + }; + match data.binary_search(&cursor) { + Ok(idx) => idx + 1, + Err(idx) => idx, + } + } + None => 0, + }; + + let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; + let end = start.saturating_add(effective_limit).min(total); + let page = data[start..end].to_vec(); + let next_cursor = page.last().filter(|_| end < total).cloned(); + + Ok(ThreadLoadedListResponse { + data: page, + next_cursor, + }) + } + + async fn thread_read_response_inner( + &self, + params: ThreadReadParams, + ) -> Result { + let ThreadReadParams { + thread_id, + include_turns, + } = params; + + let thread_uuid = ThreadId::from_string(&thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + + let thread = self + .read_thread_view(thread_uuid, include_turns) + .await + .map_err(thread_read_view_error)?; + Ok(ThreadReadResponse { thread }) + } + + /// Builds the API view for `thread/read` from persisted metadata plus optional live state. + async fn read_thread_view( + &self, + thread_id: ThreadId, + include_turns: bool, + ) -> Result { + let loaded_thread = self.thread_manager.get_thread(thread_id).await.ok(); + let mut thread = if include_turns { + if let Some(loaded_thread) = loaded_thread.as_ref() { + // Loaded thread with turns: use persisted metadata when it exists, + // but reconstruct turns from the live ThreadStore history. + let persisted_thread = self + .load_persisted_thread_for_read(thread_id, /*include_turns*/ false) + .await?; + self.load_live_thread_view( + thread_id, + include_turns, + loaded_thread, + persisted_thread, + ) + .await? + } else if let Some(thread) = self + .load_persisted_thread_for_read(thread_id, include_turns) + .await? + { + // Unloaded thread with turns: load metadata and history together + // from the ThreadStore. + thread + } else { + return Err(ThreadReadViewError::InvalidRequest(format!( + "thread not loaded: {thread_id}" + ))); + } + } else if let Some(thread) = self + .load_persisted_thread_for_read(thread_id, include_turns) + .await? + { + // Persisted metadata-only read: no live thread state is needed. + thread + } else if let Some(loaded_thread) = loaded_thread.as_ref() { + // Loaded metadata-only read before persistence is materialized: build + // the response from the live thread snapshot. + self.load_live_thread_view( + thread_id, + include_turns, + loaded_thread, + /*persisted_thread*/ None, + ) + .await? + } else { + return Err(ThreadReadViewError::InvalidRequest(format!( + "thread not loaded: {thread_id}" + ))); + }; + + let has_live_in_progress_turn = if let Some(loaded_thread) = loaded_thread.as_ref() { + matches!(loaded_thread.agent_status().await, AgentStatus::Running) + } else { + false + }; + + let thread_status = self + .thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await; + + set_thread_status_and_interrupt_stale_turns( + &mut thread, + thread_status, + has_live_in_progress_turn, + ); + Ok(thread) + } + + async fn load_persisted_thread_for_read( + &self, + thread_id: ThreadId, + include_turns: bool, + ) -> Result, ThreadReadViewError> { + let fallback_provider = self.config.model_provider_id.as_str(); + match self + .thread_store + .read_thread(StoreReadThreadParams { + thread_id, + include_archived: true, + include_history: include_turns, + }) + .await + { + Ok(stored_thread) => { + let (mut thread, history) = + thread_from_stored_thread(stored_thread, fallback_provider, &self.config.cwd); + if include_turns && let Some(history) = history { + thread.turns = build_api_turns_from_rollout_items(&history.items); + } + Ok(Some(thread)) + } + Err(ThreadStoreError::InvalidRequest { message }) + if message == format!("no rollout found for thread id {thread_id}") => + { + Ok(None) + } + Err(ThreadStoreError::ThreadNotFound { + thread_id: missing_thread_id, + }) if missing_thread_id == thread_id => Ok(None), + Err(ThreadStoreError::InvalidRequest { message }) => { + Err(ThreadReadViewError::InvalidRequest(message)) + } + Err(err) => Err(ThreadReadViewError::Internal(format!( + "failed to read thread: {err}" + ))), + } + } + + /// Builds a `thread/read` view from a loaded thread plus optional persisted metadata. + async fn load_live_thread_view( + &self, + thread_id: ThreadId, + include_turns: bool, + loaded_thread: &CodexThread, + persisted_thread: Option, + ) -> Result { + let config_snapshot = loaded_thread.config_snapshot().await; + if include_turns && config_snapshot.ephemeral { + return Err(ThreadReadViewError::InvalidRequest( + "ephemeral threads do not support includeTurns".to_string(), + )); + } + let fallback_thread = + build_thread_from_loaded_snapshot(thread_id, &config_snapshot, loaded_thread); + let mut thread = if let Some(mut thread) = persisted_thread { + if thread.path.is_none() { + thread.path = fallback_thread.path.clone(); + } + thread.session_id.clone_from(&fallback_thread.session_id); + thread.ephemeral = fallback_thread.ephemeral; + thread + } else { + fallback_thread + }; + self.apply_thread_read_store_fields(thread_id, &mut thread, include_turns, loaded_thread) + .await?; + Ok(thread) + } + + async fn apply_thread_read_store_fields( + &self, + thread_id: ThreadId, + thread: &mut Thread, + include_turns: bool, + loaded_thread: &CodexThread, + ) -> Result<(), ThreadReadViewError> { + self.attach_thread_name(thread_id, thread).await; + + if include_turns { + let history = loaded_thread + .load_history(/*include_archived*/ true) + .await + .map_err(|err| thread_read_history_load_error(thread_id, err))?; + thread.turns = build_api_turns_from_rollout_items(&history.items); + } + + Ok(()) + } + + async fn thread_turns_list_response_inner( + &self, + params: ThreadTurnsListParams, + ) -> Result { + let ThreadTurnsListParams { + thread_id, + cursor, + limit, + sort_direction, + items_view, + } = params; + let items_view = items_view.unwrap_or(TurnItemsView::Summary); + + let thread_uuid = ThreadId::from_string(&thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + + let items = self + .load_thread_turns_list_history(thread_uuid) + .await + .map_err(thread_read_view_error)?; + // This API optimizes network transfer by letting clients page through a + // thread's turns incrementally, but it still replays the entire rollout on + // every request. Rollback and compaction events can change earlier turns, so + // the server has to rebuild the full turn list until turn metadata is indexed + // separately. + let loaded_thread = self.thread_manager.get_thread(thread_uuid).await.ok(); + let has_live_running_thread = match loaded_thread.as_ref() { + Some(thread) => matches!(thread.agent_status().await, AgentStatus::Running), + None => false, + }; + let active_turn = if loaded_thread.is_some() { + // Persisted history may not yet include the currently running turn. The + // app-server listener has already projected live turn events into ThreadState, + // so merge that in-memory snapshot before paginating. + let thread_state = self.thread_state_manager.thread_state(thread_uuid).await; + let state = thread_state.lock().await; + state.active_turn_snapshot() + } else { + None + }; + let mut turns = reconstruct_thread_turns_for_turns_list( + &items, + self.thread_watch_manager + .loaded_status_for_thread(&thread_uuid.to_string()) + .await, + has_live_running_thread, + active_turn, + ); + for turn in &mut turns { + match items_view { + TurnItemsView::NotLoaded => { + turn.items.clear(); + turn.items_view = TurnItemsView::NotLoaded; + } + TurnItemsView::Summary => { + let first_user_message = turn + .items + .iter() + .find(|item| matches!(item, ThreadItem::UserMessage { .. })) + .cloned(); + let final_agent_message = turn + .items + .iter() + .rev() + .find(|item| matches!(item, ThreadItem::AgentMessage { .. })) + .cloned(); + turn.items = match (first_user_message, final_agent_message) { + (Some(user_message), Some(agent_message)) + if user_message.id() != agent_message.id() => + { + vec![user_message, agent_message] + } + (Some(user_message), _) => vec![user_message], + (None, Some(agent_message)) => vec![agent_message], + (None, None) => Vec::new(), + }; + turn.items_view = TurnItemsView::Summary; + } + TurnItemsView::Full => { + turn.items_view = TurnItemsView::Full; + } + } + } + let page = paginate_thread_turns( + turns, + cursor.as_deref(), + limit, + sort_direction.unwrap_or(SortDirection::Desc), + )?; + Ok(ThreadTurnsListResponse { + data: page.turns, + next_cursor: page.next_cursor, + backwards_cursor: page.backwards_cursor, + }) + } + + async fn load_thread_turns_list_history( + &self, + thread_id: ThreadId, + ) -> Result, ThreadReadViewError> { + match self + .thread_store + .read_thread(StoreReadThreadParams { + thread_id, + include_archived: true, + include_history: true, + }) + .await + { + Ok(stored_thread) => { + let history = stored_thread.history.ok_or_else(|| { + ThreadReadViewError::Internal(format!( + "thread store did not return history for thread {thread_id}" + )) + })?; + return Ok(history.items); + } + Err(ThreadStoreError::InvalidRequest { message }) + if message == format!("no rollout found for thread id {thread_id}") => {} + Err(ThreadStoreError::ThreadNotFound { + thread_id: missing_thread_id, + }) if missing_thread_id == thread_id => {} + Err(ThreadStoreError::InvalidRequest { message }) => { + return Err(ThreadReadViewError::InvalidRequest(message)); + } + Err(err) => { + return Err(ThreadReadViewError::Internal(format!( + "failed to read thread: {err}" + ))); + } + } + + let thread = self + .thread_manager + .get_thread(thread_id) + .await + .map_err(|_| { + ThreadReadViewError::InvalidRequest(format!("thread not loaded: {thread_id}")) + })?; + let config_snapshot = thread.config_snapshot().await; + if config_snapshot.ephemeral { + return Err(ThreadReadViewError::InvalidRequest( + "ephemeral threads do not support thread/turns/list".to_string(), + )); + } + + thread + .load_history(/*include_archived*/ true) + .await + .map(|history| history.items) + .map_err(|err| thread_turns_list_history_load_error(thread_id, err)) + } + + pub(crate) fn thread_created_receiver(&self) -> broadcast::Receiver { + self.thread_manager.subscribe_thread_created() + } + + pub(crate) async fn connection_initialized( + &self, + connection_id: ConnectionId, + capabilities: ConnectionCapabilities, + ) { + self.thread_state_manager + .connection_initialized(connection_id, capabilities) + .await; + } + + pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) { + let thread_ids = self + .thread_state_manager + .remove_connection(connection_id) + .await; + + for thread_id in thread_ids { + if self.thread_manager.get_thread(thread_id).await.is_err() { + // Reconcile stale app-server bookkeeping when the thread has already been + // removed from the core manager. + self.finalize_thread_teardown(thread_id).await; + } + } + } + + pub(crate) fn subscribe_running_assistant_turn_count(&self) -> watch::Receiver { + self.thread_watch_manager.subscribe_running_turn_count() + } + + /// Best-effort: ensure initialized connections are subscribed to this thread. + pub(crate) async fn try_attach_thread_listener( + &self, + thread_id: ThreadId, + connection_ids: Vec, + ) { + if let Ok(thread) = self.thread_manager.get_thread(thread_id).await { + let config_snapshot = thread.config_snapshot().await; + let loaded_thread = build_thread_from_snapshot( + thread_id, + thread.session_configured().session_id.to_string(), + &config_snapshot, + thread.rollout_path(), + ); + self.thread_watch_manager.upsert_thread(loaded_thread).await; + } + + for connection_id in connection_ids { + log_listener_attach_result( + self.ensure_conversation_listener( + thread_id, + connection_id, + /*raw_events_enabled*/ false, + ) + .await, + thread_id, + connection_id, + "thread", + ); + } + } + + async fn thread_resume_inner( + &self, + request_id: ConnectionRequestId, + params: ThreadResumeParams, + app_server_client_name: Option, + app_server_client_version: Option, + ) -> Result<(), JSONRPCErrorError> { + if let Ok(thread_id) = ThreadId::from_string(¶ms.thread_id) + && self + .pending_thread_unloads + .lock() + .await + .contains(&thread_id) + { + self.outgoing + .send_error( + request_id, + invalid_request(format!( + "thread {thread_id} is closing; retry thread/resume after the thread is closed" + )), + ) + .await; + return Ok(()); + } + + if params.sandbox.is_some() && params.permissions.is_some() { + self.outgoing + .send_error( + request_id, + invalid_request("`permissions` cannot be combined with `sandbox`"), + ) + .await; + return Ok(()); + } + if params.persist_extended_history { + self.send_persist_extended_history_deprecation_notice(request_id.connection_id) + .await; + } + let redact_resume_payloads = + should_redact_thread_resume_payloads(app_server_client_name.as_deref()); + + let _thread_list_state_permit = match self.acquire_thread_list_state_permit().await { + Ok(permit) => permit, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return Ok(()); + } + }; + match self + .resume_running_thread( + &request_id, + ¶ms, + app_server_client_name.clone(), + app_server_client_version.clone(), + ) + .await + { + Ok(true) => return Ok(()), + Ok(false) => {} + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return Ok(()); + } + } + + let ThreadResumeParams { + thread_id, + history, + path, + model, + model_provider, + service_tier, + cwd, + approval_policy, + approvals_reviewer, + sandbox, + permissions, + config: mut request_overrides, + base_instructions, + developer_instructions, + personality, + exclude_turns, + persist_extended_history: _persist_extended_history, + } = params; + let include_turns = !exclude_turns; + + let (thread_history, resume_source_thread) = match if let Some(history) = history { + self.resume_thread_from_history(history.as_slice()) + .await + .map(|thread_history| (thread_history, None)) + } else { + self.resume_thread_from_rollout(&thread_id, path.as_ref()) + .await + .map(|(thread_history, stored_thread)| (thread_history, Some(stored_thread))) + } { + Ok(value) => value, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return Ok(()); + } + }; + + let history_cwd = thread_history.session_cwd(); + let mut typesafe_overrides = self.build_thread_config_overrides( + model, + model_provider, + service_tier, + cwd, + approval_policy, + approvals_reviewer, + sandbox, + permissions, + base_instructions, + developer_instructions, + personality, + ); + self.load_and_apply_persisted_resume_metadata( + &thread_history, + &mut request_overrides, + &mut typesafe_overrides, + ) + .await; + + // Derive a Config using the same logic as new conversation, honoring overrides if provided. + let config = match self + .config_manager + .load_for_cwd(request_overrides, typesafe_overrides, history_cwd) + .await + { + Ok(config) => config, + Err(err) => { + let error = config_load_error(&err); + self.outgoing.send_error(request_id, error).await; + return Ok(()); + } + }; + + let instruction_sources = Self::instruction_sources_from_config(&config).await; + let response_history = thread_history.clone(); + + match self + .thread_manager + .resume_thread_with_history( + config.clone(), + thread_history, + self.auth_manager.clone(), + /*persist_extended_history*/ false, + self.request_trace_context(&request_id).await, + ) + .await + { + Ok(NewThread { + thread_id, + thread: codex_thread, + session_configured, + .. + }) => { + if let Err(err) = Self::set_app_server_client_info( + codex_thread.as_ref(), + app_server_client_name, + app_server_client_version, + ) + .await + { + self.outgoing.send_error(request_id, err).await; + return Ok(()); + } + let SessionConfiguredEvent { rollout_path, .. } = session_configured; + let Some(rollout_path) = rollout_path else { + let error = + internal_error(format!("rollout path missing for thread {thread_id}")); + self.outgoing.send_error(request_id, error).await; + return Ok(()); + }; + // Auto-attach a thread listener when resuming a thread. + log_listener_attach_result( + self.ensure_conversation_listener( + thread_id, + request_id.connection_id, + /*raw_events_enabled*/ false, + ) + .await, + thread_id, + request_id.connection_id, + "thread", + ); + + let mut thread = match self + .load_thread_from_resume_source_or_send_internal( + thread_id, + codex_thread.as_ref(), + &response_history, + rollout_path.as_path(), + resume_source_thread, + include_turns, + ) + .await + { + Ok(thread) => thread, + Err(message) => { + self.outgoing + .send_error(request_id, internal_error(message)) + .await; + return Ok(()); + } + }; + thread.thread_source = codex_thread + .config_snapshot() + .await + .thread_source + .map(Into::into); + + self.thread_watch_manager + .upsert_thread(thread.clone()) + .await; + + let thread_status = self + .thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await; + + set_thread_status_and_interrupt_stale_turns( + &mut thread, + thread_status, + /*has_live_in_progress_turn*/ false, + ); + let config_snapshot = codex_thread.config_snapshot().await; + let sandbox = thread_response_sandbox_policy( + &config_snapshot.permission_profile, + config_snapshot.cwd.as_path(), + ); + let active_permission_profile = thread_response_active_permission_profile( + config_snapshot.active_permission_profile, + ); + let token_usage_thread = include_turns.then(|| thread.clone()); + if redact_resume_payloads { + redact_thread_resume_payloads(&mut thread); + } + + let response = ThreadResumeResponse { + thread, + model: session_configured.model, + model_provider: session_configured.model_provider_id, + service_tier: session_configured.service_tier, + cwd: session_configured.cwd, + instruction_sources, + approval_policy: session_configured.approval_policy.into(), + approvals_reviewer: session_configured.approvals_reviewer.into(), + sandbox, + permission_profile: Some(config_snapshot.permission_profile.into()), + active_permission_profile, + reasoning_effort: session_configured.reasoning_effort, + }; + + let connection_id = request_id.connection_id; + self.outgoing.send_response(request_id, response).await; + // `excludeTurns` is explicitly the cheap resume path, so avoid + // rebuilding history only to attribute a replayed usage update. + if let Some(token_usage_thread) = token_usage_thread { + let token_usage_turn_id = latest_token_usage_turn_id_from_rollout_items( + &response_history.get_rollout_items(), + token_usage_thread.turns.as_slice(), + ); + // The client needs restored usage before it starts another turn. + // Sending after the response preserves JSON-RPC request ordering while + // still filling the status line before the next turn lifecycle begins. + send_thread_token_usage_update_to_connection( + &self.outgoing, + connection_id, + thread_id, + &token_usage_thread, + codex_thread.as_ref(), + token_usage_turn_id, + ) + .await; + } + self.thread_goal_processor + .emit_resume_goal_snapshot_and_continue(thread_id, codex_thread.as_ref()) + .await; + } + Err(err) => { + let error = internal_error(format!("error resuming thread: {err}")); + self.outgoing.send_error(request_id, error).await; + } + } + Ok(()) + } + + async fn load_and_apply_persisted_resume_metadata( + &self, + thread_history: &InitialHistory, + request_overrides: &mut Option>, + typesafe_overrides: &mut ConfigOverrides, + ) -> Option { + let InitialHistory::Resumed(resumed_history) = thread_history else { + return None; + }; + let state_db_ctx = self.state_db.clone()?; + let persisted_metadata = state_db_ctx + .get_thread(resumed_history.conversation_id) + .await + .ok() + .flatten()?; + merge_persisted_resume_metadata(request_overrides, typesafe_overrides, &persisted_metadata); + Some(persisted_metadata) + } + + async fn resume_running_thread( + &self, + request_id: &ConnectionRequestId, + params: &ThreadResumeParams, + app_server_client_name: Option, + app_server_client_version: Option, + ) -> Result { + let running_thread = if params.history.is_some() { + if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id) + && self + .thread_manager + .get_thread(existing_thread_id) + .await + .is_ok() + { + return Err(invalid_request(format!( + "cannot resume thread {existing_thread_id} with history while it is already running" + ))); + } + None + } else if params.path.is_some() { + let source_thread = self + .read_stored_thread_for_resume( + ¶ms.thread_id, + params.path.as_ref(), + /*include_history*/ true, + ) + .await?; + let existing_thread_id = source_thread.thread_id; + if let Ok(existing_thread) = self.thread_manager.get_thread(existing_thread_id).await { + if let (Some(requested_path), Some(active_path)) = ( + params.path.as_ref(), + existing_thread.rollout_path().as_ref(), + ) && requested_path != active_path + { + return Err(invalid_request(format!( + "cannot resume running thread {existing_thread_id} with stale path: requested `{}`, active `{}`", + requested_path.display(), + active_path.display() + ))); + } + Some((existing_thread_id, existing_thread, source_thread)) + } else { + None + } + } else if let Ok(existing_thread_id) = ThreadId::from_string(¶ms.thread_id) + && let Ok(existing_thread) = self.thread_manager.get_thread(existing_thread_id).await + { + let source_thread = self + .read_stored_thread_for_resume( + ¶ms.thread_id, + /*path*/ None, + /*include_history*/ true, + ) + .await?; + if source_thread.thread_id != existing_thread_id { + return Err(invalid_request(format!( + "cannot resume running thread {existing_thread_id} from source thread {}", + source_thread.thread_id + ))); + } + Some((existing_thread_id, existing_thread, source_thread)) + } else { + None + }; + + if let Some((existing_thread_id, existing_thread, source_thread)) = running_thread { + let redact_resume_payloads = + should_redact_thread_resume_payloads(app_server_client_name.as_deref()); + let history_items = source_thread + .history + .as_ref() + .map(|history| history.items.clone()) + .ok_or_else(|| { + internal_error(format!( + "thread {existing_thread_id} did not include persisted history" + )) + })?; + + let thread_state = self + .thread_state_manager + .thread_state(existing_thread_id) + .await; + self.ensure_listener_task_running( + existing_thread_id, + existing_thread.clone(), + thread_state.clone(), + ) + .await?; + Self::set_app_server_client_info( + existing_thread.as_ref(), + app_server_client_name, + app_server_client_version, + ) + .await?; + + let config_snapshot = existing_thread.config_snapshot().await; + let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot); + if !mismatch_details.is_empty() { + tracing::warn!( + "thread/resume overrides ignored for running thread {}: {}", + existing_thread_id, + mismatch_details.join("; ") + ); + } + let mut summary_source_thread = source_thread; + summary_source_thread.history = None; + let mut thread_summary = self.stored_thread_to_api_thread( + summary_source_thread, + config_snapshot.model_provider_id.as_str(), + /*include_turns*/ false, + ); + thread_summary.session_id = existing_thread.session_configured().session_id.to_string(); + let mut config_for_instruction_sources = self.config.as_ref().clone(); + config_for_instruction_sources.cwd = config_snapshot.cwd.clone(); + let instruction_sources = + Self::instruction_sources_from_config(&config_for_instruction_sources).await; + + let listener_command_tx = { + let thread_state = thread_state.lock().await; + thread_state.listener_command_tx() + }; + let Some(listener_command_tx) = listener_command_tx else { + return Err(internal_error(format!( + "failed to enqueue running thread resume for thread {existing_thread_id}: thread listener is not running" + ))); + }; + + let (emit_thread_goal_update, thread_goal_state_db) = self + .thread_goal_processor + .pending_resume_goal_state(existing_thread.as_ref()) + .await; + + let command = crate::thread_state::ThreadListenerCommand::SendThreadResumeResponse( + Box::new(crate::thread_state::PendingThreadResumeRequest { + request_id: request_id.clone(), + history_items, + config_snapshot, + instruction_sources, + thread_summary, + emit_thread_goal_update, + thread_goal_state_db, + include_turns: !params.exclude_turns, + redact_resume_payloads, + }), + ); + if listener_command_tx.send(command).is_err() { + return Err(internal_error(format!( + "failed to enqueue running thread resume for thread {existing_thread_id}: thread listener command channel is closed" + ))); + } + return Ok(true); + } + Ok(false) + } + + async fn resume_thread_from_history( + &self, + history: &[ResponseItem], + ) -> Result { + if history.is_empty() { + return Err(invalid_request("history must not be empty")); + } + Ok(InitialHistory::Forked( + history + .iter() + .cloned() + .map(RolloutItem::ResponseItem) + .collect(), + )) + } + + async fn resume_thread_from_rollout( + &self, + thread_id: &str, + path: Option<&PathBuf>, + ) -> Result<(InitialHistory, StoredThread), JSONRPCErrorError> { + let stored_thread = self + .read_stored_thread_for_resume(thread_id, path, /*include_history*/ true) + .await?; + let history = self + .stored_thread_to_initial_history(&stored_thread) + .await?; + Ok((history, stored_thread)) + } + + async fn read_stored_thread_for_resume( + &self, + thread_id: &str, + path: Option<&PathBuf>, + include_history: bool, + ) -> Result { + let result = if let Some(path) = path { + self.thread_store + .read_thread_by_rollout_path(StoreReadThreadByRolloutPathParams { + rollout_path: path.clone(), + include_archived: true, + include_history, + }) + .await + } else { + let existing_thread_id = match ThreadId::from_string(thread_id) { + Ok(id) => id, + Err(err) => { + return Err(invalid_request(format!("invalid thread id: {err}"))); + } + }; + let params = StoreReadThreadParams { + thread_id: existing_thread_id, + include_archived: true, + include_history, + }; + self.thread_store.read_thread(params).await + }; + + result.map_err(thread_store_resume_read_error) + } + + async fn stored_thread_to_initial_history( + &self, + stored_thread: &StoredThread, + ) -> Result { + let thread_id = stored_thread.thread_id; + let history = stored_thread + .history + .as_ref() + .map(|history| history.items.clone()) + .ok_or_else(|| { + internal_error(format!( + "thread {thread_id} did not include persisted history" + )) + })?; + Ok(InitialHistory::Resumed(ResumedHistory { + conversation_id: thread_id, + history, + rollout_path: stored_thread.rollout_path.clone(), + })) + } + + fn stored_thread_to_api_thread( + &self, + stored_thread: StoredThread, + fallback_provider: &str, + include_turns: bool, + ) -> Thread { + let (mut thread, history) = + thread_from_stored_thread(stored_thread, fallback_provider, &self.config.cwd); + if include_turns && let Some(history) = history { + populate_thread_turns_from_history( + &mut thread, + &history.items, + /*active_turn*/ None, + ); + } + thread + } + + async fn read_stored_thread_for_new_fork( + &self, + thread_id: ThreadId, + include_history: bool, + ) -> Result { + self.thread_store + .read_thread(StoreReadThreadParams { + thread_id, + include_archived: true, + include_history, + }) + .await + .map_err(thread_store_resume_read_error) + } + + async fn load_thread_from_resume_source_or_send_internal( + &self, + thread_id: ThreadId, + thread: &CodexThread, + thread_history: &InitialHistory, + rollout_path: &Path, + resume_source_thread: Option, + include_turns: bool, + ) -> std::result::Result { + let config_snapshot = thread.config_snapshot().await; + let session_id = thread.session_configured().session_id.to_string(); + let thread = match thread_history { + InitialHistory::Resumed(resumed) => { + let fallback_provider = config_snapshot.model_provider_id.as_str(); + if let Some(stored_thread) = resume_source_thread { + let stored_thread = + if let Some(rollout_path) = stored_thread.rollout_path.clone() { + self.thread_store + .read_thread_by_rollout_path(StoreReadThreadByRolloutPathParams { + rollout_path, + include_archived: true, + include_history: false, + }) + .await + .unwrap_or(StoredThread { + history: None, + ..stored_thread + }) + } else { + self.thread_store + .read_thread(StoreReadThreadParams { + thread_id: stored_thread.thread_id, + include_archived: true, + include_history: false, + }) + .await + .unwrap_or(StoredThread { + history: None, + ..stored_thread + }) + }; + Ok(thread_from_stored_thread( + stored_thread, + fallback_provider, + &self.config.cwd, + ) + .0) + } else { + match self + .thread_store + .read_thread(StoreReadThreadParams { + thread_id: resumed.conversation_id, + include_archived: true, + include_history: false, + }) + .await + { + Ok(stored_thread) => Ok(thread_from_stored_thread( + stored_thread, + fallback_provider, + &self.config.cwd, + ) + .0), + Err(read_err) => { + Err(format!("failed to read thread from store: {read_err}")) + } + } + } + } + InitialHistory::Forked(items) => { + let mut thread = build_thread_from_snapshot( + thread_id, + session_id.clone(), + &config_snapshot, + Some(rollout_path.into()), + ); + thread.preview = preview_from_rollout_items(items); + Ok(thread) + } + InitialHistory::New | InitialHistory::Cleared => Err(format!( + "failed to build resume response for thread {thread_id}: initial history missing" + )), + }; + let mut thread = thread?; + thread.id = thread_id.to_string(); + thread.session_id = session_id; + thread.path = Some(rollout_path.to_path_buf()); + if include_turns { + let history_items = thread_history.get_rollout_items(); + populate_thread_turns_from_history( + &mut thread, + &history_items, + /*active_turn*/ None, + ); + } + self.attach_thread_name(thread_id, &mut thread).await; + Ok(thread) + } + + async fn attach_thread_name(&self, thread_id: ThreadId, thread: &mut Thread) { + if let Ok(stored_thread) = self + .thread_store + .read_thread(StoreReadThreadParams { + thread_id, + include_archived: true, + include_history: false, + }) + .await + && let Some(title) = stored_thread.name.as_deref().map(str::trim) + && !title.is_empty() + && stored_thread.preview.trim() != title + { + set_thread_name_from_title(thread, title.to_string()); + } + } + + async fn thread_fork_inner( + &self, + request_id: ConnectionRequestId, + params: ThreadForkParams, + app_server_client_name: Option, + app_server_client_version: Option, + ) -> Result<(), JSONRPCErrorError> { + let ThreadForkParams { + thread_id, + path, + model, + model_provider, + service_tier, + cwd, + approval_policy, + approvals_reviewer, + sandbox, + permissions, + config: cli_overrides, + base_instructions, + developer_instructions, + ephemeral, + thread_source, + exclude_turns, + persist_extended_history, + } = params; + let include_turns = !exclude_turns; + if sandbox.is_some() && permissions.is_some() { + return Err(invalid_request( + "`permissions` cannot be combined with `sandbox`", + )); + } + if persist_extended_history { + self.send_persist_extended_history_deprecation_notice(request_id.connection_id) + .await; + } + + let source_thread = self + .read_stored_thread_for_resume(&thread_id, path.as_ref(), /*include_history*/ true) + .await?; + let source_thread_id = source_thread.thread_id; + let history_items = source_thread + .history + .as_ref() + .map(|history| history.items.clone()) + .ok_or_else(|| { + internal_error(format!( + "thread {source_thread_id} did not include persisted history" + )) + })?; + let history_cwd = Some(source_thread.cwd.clone()); + + // Persist Windows sandbox mode. + let mut cli_overrides = cli_overrides.unwrap_or_default(); + if cfg!(windows) { + match WindowsSandboxLevel::from_config(&self.config) { + WindowsSandboxLevel::Elevated => { + cli_overrides + .insert("windows.sandbox".to_string(), serde_json::json!("elevated")); + } + WindowsSandboxLevel::RestrictedToken => { + cli_overrides.insert( + "windows.sandbox".to_string(), + serde_json::json!("unelevated"), + ); + } + WindowsSandboxLevel::Disabled => {} + } + } + let request_overrides = if cli_overrides.is_empty() { + None + } else { + Some(cli_overrides) + }; + let mut typesafe_overrides = self.build_thread_config_overrides( + model, + model_provider, + service_tier, + cwd, + approval_policy, + approvals_reviewer, + sandbox, + permissions, + base_instructions, + developer_instructions, + /*personality*/ None, + ); + typesafe_overrides.ephemeral = ephemeral.then_some(true); + // Derive a Config using the same logic as new conversation, honoring overrides if provided. + let config = self + .config_manager + .load_for_cwd(request_overrides, typesafe_overrides, history_cwd) + .await + .map_err(|err| config_load_error(&err))?; + + let fallback_model_provider = config.model_provider_id.clone(); + let instruction_sources = Self::instruction_sources_from_config(&config).await; + + let NewThread { + thread_id, + thread: forked_thread, + session_configured, + .. + } = self + .thread_manager + .fork_thread_from_history( + ForkSnapshot::Interrupted, + config, + InitialHistory::Resumed(ResumedHistory { + conversation_id: source_thread_id, + history: history_items.clone(), + rollout_path: source_thread.rollout_path.clone(), + }), + thread_source.map(Into::into), + /*persist_extended_history*/ false, + self.request_trace_context(&request_id).await, + ) + .await + .map_err(|err| match err { + CodexErr::Io(_) | CodexErr::Json(_) => { + invalid_request(format!("failed to load thread {source_thread_id}: {err}")) + } + CodexErr::InvalidRequest(message) => invalid_request(message), + err => internal_error(format!("error forking thread: {err}")), + })?; + + Self::set_app_server_client_info( + forked_thread.as_ref(), + app_server_client_name, + app_server_client_version, + ) + .await?; + + // Auto-attach a conversation listener when forking a thread. + log_listener_attach_result( + self.ensure_conversation_listener( + thread_id, + request_id.connection_id, + /*raw_events_enabled*/ false, + ) + .await, + thread_id, + request_id.connection_id, + "thread", + ); + + // Persistent forks materialize their own rollout immediately. Ephemeral forks stay + // pathless, so they rebuild their visible history from the copied source history instead. + let mut thread = if session_configured.rollout_path.is_some() { + let stored_thread = self + .read_stored_thread_for_new_fork(thread_id, include_turns) + .await?; + self.stored_thread_to_api_thread( + stored_thread, + fallback_model_provider.as_str(), + include_turns, + ) + } else { + let config_snapshot = forked_thread.config_snapshot().await; + // forked thread names do not inherit the source thread name + let mut thread = build_thread_from_snapshot( + thread_id, + session_configured.session_id.to_string(), + &config_snapshot, + /*path*/ None, + ); + thread.preview = preview_from_rollout_items(&history_items); + thread.forked_from_id = Some(source_thread_id.to_string()); + if include_turns { + populate_thread_turns_from_history( + &mut thread, + &history_items, + /*active_turn*/ None, + ); + } + thread + }; + thread.session_id = session_configured.session_id.to_string(); + thread.thread_source = forked_thread + .config_snapshot() + .await + .thread_source + .map(Into::into); + + self.thread_watch_manager + .upsert_thread_silently(thread.clone()) + .await; + + thread.status = resolve_thread_status( + self.thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await, + /*has_in_progress_turn*/ false, + ); + let config_snapshot = forked_thread.config_snapshot().await; + let sandbox = thread_response_sandbox_policy( + &config_snapshot.permission_profile, + config_snapshot.cwd.as_path(), + ); + let active_permission_profile = + thread_response_active_permission_profile(config_snapshot.active_permission_profile); + + let response = ThreadForkResponse { + thread: thread.clone(), + model: session_configured.model, + model_provider: session_configured.model_provider_id, + service_tier: session_configured.service_tier, + cwd: session_configured.cwd, + instruction_sources, + approval_policy: session_configured.approval_policy.into(), + approvals_reviewer: session_configured.approvals_reviewer.into(), + sandbox, + permission_profile: Some(config_snapshot.permission_profile.into()), + active_permission_profile, + reasoning_effort: session_configured.reasoning_effort, + }; + + let notif = thread_started_notification(thread); + let connection_id = request_id.connection_id; + let token_usage_thread = include_turns.then(|| response.thread.clone()); + self.outgoing.send_response(request_id, response).await; + // `excludeTurns` is the cheap fork path, so skip restored usage replay + // instead of rebuilding history only to attribute a historical update. + if let Some(token_usage_thread) = token_usage_thread { + let token_usage_turn_id = latest_token_usage_turn_id_from_rollout_items( + &history_items, + token_usage_thread.turns.as_slice(), + ); + // Mirror the resume contract for forks: the new thread is usable as soon + // as the response arrives, so restored usage must follow immediately. + send_thread_token_usage_update_to_connection( + &self.outgoing, + connection_id, + thread_id, + &token_usage_thread, + forked_thread.as_ref(), + token_usage_turn_id, + ) + .await; + } + + self.outgoing + .send_server_notification(ServerNotification::ThreadStarted(notif)) + .await; + Ok(()) + } + + async fn get_thread_summary_response_inner( + &self, + params: GetConversationSummaryParams, + ) -> Result { + let fallback_provider = self.config.model_provider_id.as_str(); + let read_result = match params { + GetConversationSummaryParams::ThreadId { conversation_id } => self + .thread_store + .read_thread(StoreReadThreadParams { + thread_id: conversation_id, + include_archived: true, + include_history: false, + }) + .await + .map_err(|err| conversation_summary_thread_id_read_error(conversation_id, err)), + GetConversationSummaryParams::RolloutPath { rollout_path } => { + let Some(local_thread_store) = self + .thread_store + .as_any() + .downcast_ref::() + else { + return Err(invalid_request( + "rollout path queries are only supported with the local thread store", + )); + }; + + local_thread_store + .read_thread_by_rollout_path( + rollout_path.clone(), + /*include_archived*/ true, + /*include_history*/ false, + ) + .await + .map_err(|err| conversation_summary_rollout_path_read_error(&rollout_path, err)) + } + }; + + let stored_thread = read_result?; + let summary = summary_from_stored_thread(stored_thread, fallback_provider); + Ok(GetConversationSummaryResponse { summary }) + } + + async fn list_threads_common( + &self, + requested_page_size: usize, + cursor: Option, + sort_key: StoreThreadSortKey, + sort_direction: SortDirection, + filters: ThreadListFilters, + ) -> Result<(Vec, Option), JSONRPCErrorError> { + let ThreadListFilters { + model_providers, + source_kinds, + archived, + cwd_filters, + search_term, + use_state_db_only, + } = filters; + let mut cursor_obj = cursor; + let mut last_cursor = cursor_obj.clone(); + let mut remaining = requested_page_size; + let mut items = Vec::with_capacity(requested_page_size); + let mut next_cursor: Option = None; + + let model_provider_filter = match model_providers { + Some(providers) => { + if providers.is_empty() { + None + } else { + Some(providers) + } + } + None => Some(vec![self.config.model_provider_id.clone()]), + }; + let (allowed_sources_vec, source_kind_filter) = compute_source_filters(source_kinds); + let allowed_sources = allowed_sources_vec.as_slice(); + let store_sort_direction = match sort_direction { + SortDirection::Asc => StoreSortDirection::Asc, + SortDirection::Desc => StoreSortDirection::Desc, + }; + + while remaining > 0 { + let page_size = remaining.min(THREAD_LIST_MAX_LIMIT); + let page = self + .thread_store + .list_threads(StoreListThreadsParams { + page_size, + cursor: cursor_obj.clone(), + sort_key, + sort_direction: store_sort_direction, + allowed_sources: allowed_sources.to_vec(), + model_providers: model_provider_filter.clone(), + cwd_filters: cwd_filters.clone(), + archived, + search_term: search_term.clone(), + use_state_db_only, + }) + .await + .map_err(thread_store_list_error)?; + + let mut filtered = Vec::with_capacity(page.items.len()); + for it in page.items { + let source = with_thread_spawn_agent_metadata( + it.source.clone(), + it.agent_nickname.clone(), + it.agent_role.clone(), + ); + if source_kind_filter + .as_ref() + .is_none_or(|filter| source_kind_matches(&source, filter)) + && cwd_filters.as_ref().is_none_or(|expected_cwds| { + expected_cwds.iter().any(|expected_cwd| { + path_utils::paths_match_after_normalization(&it.cwd, expected_cwd) + }) + }) + { + filtered.push(it); + if filtered.len() >= remaining { + break; + } + } + } + items.extend(filtered); + remaining = requested_page_size.saturating_sub(items.len()); + + next_cursor = page.next_cursor; + if remaining == 0 { + break; + } + + let Some(cursor_val) = next_cursor.clone() else { + break; + }; + // Break if our pagination would reuse the same cursor again; this avoids + // an infinite loop when filtering drops everything on the page. + if last_cursor.as_ref() == Some(&cursor_val) { + next_cursor = None; + break; + } + last_cursor = Some(cursor_val.clone()); + cursor_obj = Some(cursor_val); + } + + Ok((items, next_cursor)) + } +} + +fn xcode_26_4_mcp_elicitations_auto_deny( + client_name: Option<&str>, + client_version: Option<&str>, +) -> bool { + // Xcode 26.4 shipped before app-server MCP elicitation requests were + // client-visible. Keep elicitations auto-denied for that client line. + // TODO: Remove this compatibility hack once Xcode 26.4 ages out. + client_name == Some("Xcode") + && client_version.is_some_and(|version| version.starts_with("26.4")) +} + +const THREAD_TURNS_DEFAULT_LIMIT: usize = 25; +const THREAD_TURNS_MAX_LIMIT: usize = 100; + +fn thread_backwards_cursor_for_sort_key( + thread: &StoredThread, + sort_key: StoreThreadSortKey, + sort_direction: SortDirection, +) -> Option { + let timestamp = match sort_key { + StoreThreadSortKey::CreatedAt => thread.created_at, + StoreThreadSortKey::UpdatedAt => thread.updated_at, + }; + // The state DB stores unique millisecond timestamps. Offset the reverse cursor by one + // millisecond so the opposite-direction query includes the page anchor. + let timestamp = match sort_direction { + SortDirection::Asc => timestamp.checked_add_signed(ChronoDuration::milliseconds(1))?, + SortDirection::Desc => timestamp.checked_sub_signed(ChronoDuration::milliseconds(1))?, + }; + Some(timestamp.to_rfc3339_opts(SecondsFormat::Millis, true)) +} + +struct ThreadTurnsPage { + pub(super) turns: Vec, + pub(super) next_cursor: Option, + pub(super) backwards_cursor: Option, +} + +#[derive(serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct ThreadTurnsCursor { + turn_id: String, + include_anchor: bool, +} + +fn paginate_thread_turns( + turns: Vec, + cursor: Option<&str>, + limit: Option, + sort_direction: SortDirection, +) -> Result { + if turns.is_empty() { + return Ok(ThreadTurnsPage { + turns: Vec::new(), + next_cursor: None, + backwards_cursor: None, + }); + } + + let anchor = cursor.map(parse_thread_turns_cursor).transpose()?; + let page_size = limit + .map(|value| value as usize) + .unwrap_or(THREAD_TURNS_DEFAULT_LIMIT) + .clamp(1, THREAD_TURNS_MAX_LIMIT); + + let anchor_index = anchor + .as_ref() + .and_then(|anchor| turns.iter().position(|turn| turn.id == anchor.turn_id)); + if anchor.is_some() && anchor_index.is_none() { + return Err(invalid_request( + "invalid cursor: anchor turn is no longer present", + )); + } + + let mut keyed_turns: Vec<_> = turns.into_iter().enumerate().collect(); + match sort_direction { + SortDirection::Asc => { + if let (Some(anchor), Some(anchor_index)) = (anchor.as_ref(), anchor_index) { + keyed_turns.retain(|(index, _)| { + if anchor.include_anchor { + *index >= anchor_index + } else { + *index > anchor_index + } + }); + } + } + SortDirection::Desc => { + keyed_turns.reverse(); + if let (Some(anchor), Some(anchor_index)) = (anchor.as_ref(), anchor_index) { + keyed_turns.retain(|(index, _)| { + if anchor.include_anchor { + *index <= anchor_index + } else { + *index < anchor_index + } + }); + } + } + } + + let more_turns_available = keyed_turns.len() > page_size; + keyed_turns.truncate(page_size); + let backwards_cursor = keyed_turns + .first() + .map(|(_, turn)| serialize_thread_turns_cursor(&turn.id, /*include_anchor*/ true)) + .transpose()?; + let next_cursor = if more_turns_available { + keyed_turns + .last() + .map(|(_, turn)| serialize_thread_turns_cursor(&turn.id, /*include_anchor*/ false)) + .transpose()? + } else { + None + }; + let turns = keyed_turns.into_iter().map(|(_, turn)| turn).collect(); + + Ok(ThreadTurnsPage { + turns, + next_cursor, + backwards_cursor, + }) +} + +fn serialize_thread_turns_cursor( + turn_id: &str, + include_anchor: bool, +) -> Result { + serde_json::to_string(&ThreadTurnsCursor { + turn_id: turn_id.to_string(), + include_anchor, + }) + .map_err(|err| internal_error(format!("failed to serialize cursor: {err}"))) +} + +fn parse_thread_turns_cursor(cursor: &str) -> Result { + serde_json::from_str(cursor).map_err(|_| invalid_request(format!("invalid cursor: {cursor}"))) +} + +fn reconstruct_thread_turns_for_turns_list( + items: &[RolloutItem], + loaded_status: ThreadStatus, + has_live_running_thread: bool, + active_turn: Option, +) -> Vec { + let has_live_in_progress_turn = has_live_running_thread + || active_turn + .as_ref() + .is_some_and(|turn| matches!(turn.status, TurnStatus::InProgress)); + let mut turns = build_api_turns_from_rollout_items(items); + normalize_thread_turns_status(&mut turns, loaded_status, has_live_in_progress_turn); + if let Some(active_turn) = active_turn { + merge_turn_history_with_active_turn(&mut turns, active_turn); + } + turns +} + +fn normalize_thread_turns_status( + turns: &mut [Turn], + loaded_status: ThreadStatus, + has_live_in_progress_turn: bool, +) { + let status = resolve_thread_status(loaded_status, has_live_in_progress_turn); + if matches!(status, ThreadStatus::Active { .. }) { + return; + } + for turn in turns { + if matches!(turn.status, TurnStatus::InProgress) { + turn.status = TurnStatus::Interrupted; + } + } +} + +enum ThreadReadViewError { + InvalidRequest(String), + Unsupported(&'static str), + Internal(String), +} + +fn thread_read_view_error(err: ThreadReadViewError) -> JSONRPCErrorError { + match err { + ThreadReadViewError::InvalidRequest(message) => invalid_request(message), + ThreadReadViewError::Unsupported(operation) => { + unsupported_thread_store_operation(operation) + } + ThreadReadViewError::Internal(message) => internal_error(message), + } +} + +fn unsupported_thread_store_operation(operation: &'static str) -> JSONRPCErrorError { + method_not_found(format!("{operation} is not supported yet")) +} + +fn thread_store_list_error(err: ThreadStoreError) -> JSONRPCErrorError { + match err { + ThreadStoreError::InvalidRequest { message } => invalid_request(message), + ThreadStoreError::Unsupported { operation } => { + unsupported_thread_store_operation(operation) + } + err => internal_error(format!("failed to list threads: {err}")), + } +} + +fn thread_store_resume_read_error(err: ThreadStoreError) -> JSONRPCErrorError { + match err { + ThreadStoreError::InvalidRequest { message } => invalid_request(message), + ThreadStoreError::Unsupported { operation } => { + unsupported_thread_store_operation(operation) + } + ThreadStoreError::ThreadNotFound { thread_id } => { + invalid_request(format!("no rollout found for thread id {thread_id}")) + } + err => internal_error(format!("failed to read thread: {err}")), + } +} + +fn thread_turns_list_history_load_error( + thread_id: ThreadId, + err: ThreadStoreError, +) -> ThreadReadViewError { + match err { + ThreadStoreError::InvalidRequest { message } + if message.starts_with("failed to resolve rollout path `") => + { + ThreadReadViewError::InvalidRequest(format!( + "thread {thread_id} is not materialized yet; thread/turns/list is unavailable before first user message" + )) + } + ThreadStoreError::InvalidRequest { message } => { + ThreadReadViewError::InvalidRequest(message) + } + ThreadStoreError::Unsupported { operation } => ThreadReadViewError::Unsupported(operation), + err => ThreadReadViewError::Internal(format!( + "failed to load thread history for thread {thread_id}: {err}" + )), + } +} + +fn thread_read_history_load_error( + thread_id: ThreadId, + err: ThreadStoreError, +) -> ThreadReadViewError { + match err { + ThreadStoreError::InvalidRequest { message } + if message.starts_with("failed to resolve rollout path `") => + { + ThreadReadViewError::InvalidRequest(format!( + "thread {thread_id} is not materialized yet; includeTurns is unavailable before first user message" + )) + } + ThreadStoreError::ThreadNotFound { + thread_id: missing_thread_id, + } if missing_thread_id == thread_id => ThreadReadViewError::InvalidRequest(format!( + "thread {thread_id} is not materialized yet; includeTurns is unavailable before first user message" + )), + ThreadStoreError::InvalidRequest { message } => { + ThreadReadViewError::InvalidRequest(message) + } + ThreadStoreError::Unsupported { operation } => ThreadReadViewError::Unsupported(operation), + err => ThreadReadViewError::Internal(format!( + "failed to load thread history for thread {thread_id}: {err}" + )), + } +} + +fn conversation_summary_thread_id_read_error( + conversation_id: ThreadId, + err: ThreadStoreError, +) -> JSONRPCErrorError { + let no_rollout_message = format!("no rollout found for thread id {conversation_id}"); + match err { + ThreadStoreError::InvalidRequest { message } if message == no_rollout_message => { + conversation_summary_not_found_error(conversation_id) + } + ThreadStoreError::Unsupported { operation } => { + unsupported_thread_store_operation(operation) + } + ThreadStoreError::ThreadNotFound { thread_id } if thread_id == conversation_id => { + conversation_summary_not_found_error(conversation_id) + } + ThreadStoreError::InvalidRequest { message } => invalid_request(message), + err => internal_error(format!( + "failed to load conversation summary for {conversation_id}: {err}" + )), + } +} + +fn conversation_summary_not_found_error(conversation_id: ThreadId) -> JSONRPCErrorError { + invalid_request(format!( + "no rollout found for conversation id {conversation_id}" + )) +} + +fn conversation_summary_rollout_path_read_error( + path: &Path, + err: ThreadStoreError, +) -> JSONRPCErrorError { + match err { + ThreadStoreError::InvalidRequest { message } => invalid_request(message), + ThreadStoreError::Unsupported { operation } => { + unsupported_thread_store_operation(operation) + } + err => internal_error(format!( + "failed to load conversation summary from {}: {}", + path.display(), + err + )), + } +} + +fn core_thread_write_error(operation: &str, err: CodexErr) -> JSONRPCErrorError { + match err { + CodexErr::ThreadNotFound(thread_id) => { + invalid_request(format!("thread not found: {thread_id}")) + } + CodexErr::InvalidRequest(message) => invalid_request(message), + CodexErr::UnsupportedOperation(message) => method_not_found(message), + err => internal_error(format!("failed to {operation}: {err}")), + } +} + +fn thread_store_archive_error(operation: &str, err: ThreadStoreError) -> JSONRPCErrorError { + match err { + ThreadStoreError::InvalidRequest { message } => invalid_request(message), + ThreadStoreError::Unsupported { + operation: unsupported_operation, + } => unsupported_thread_store_operation(unsupported_operation), + err => internal_error(format!("failed to {operation} thread: {err}")), + } +} + +fn set_thread_name_from_title(thread: &mut Thread, title: String) { + if title.trim().is_empty() || thread.preview.trim() == title.trim() { + return; + } + thread.name = Some(title); +} + +pub(crate) fn thread_from_stored_thread( + thread: StoredThread, + fallback_provider: &str, + fallback_cwd: &AbsolutePathBuf, +) -> (Thread, Option) { + let path = thread.rollout_path; + let git_info = thread.git_info.map(|info| ApiGitInfo { + sha: info.commit_hash.map(|sha| sha.0), + branch: info.branch, + origin_url: info.repository_url, + }); + let cwd = AbsolutePathBuf::relative_to_current_dir(path_utils::normalize_for_native_workdir( + thread.cwd, + )) + .unwrap_or_else(|err| { + warn!("failed to normalize thread cwd while reading stored thread: {err}"); + fallback_cwd.clone() + }); + let source = with_thread_spawn_agent_metadata( + thread.source, + thread.agent_nickname.clone(), + thread.agent_role.clone(), + ); + let history = thread.history; + let thread_id = thread.thread_id.to_string(); + let thread = Thread { + id: thread_id.clone(), + session_id: thread_id, + forked_from_id: thread.forked_from_id.map(|id| id.to_string()), + preview: thread.preview, + ephemeral: false, + model_provider: if thread.model_provider.is_empty() { + fallback_provider.to_string() + } else { + thread.model_provider + }, + created_at: thread.created_at.timestamp(), + updated_at: thread.updated_at.timestamp(), + status: ThreadStatus::NotLoaded, + path, + cwd, + cli_version: thread.cli_version, + agent_nickname: source.get_nickname(), + agent_role: source.get_agent_role(), + source: source.into(), + thread_source: thread.thread_source.map(Into::into), + git_info, + name: thread.name, + turns: Vec::new(), + }; + (thread, history) +} + +fn summary_from_stored_thread( + thread: StoredThread, + fallback_provider: &str, +) -> ConversationSummary { + let path = thread.rollout_path.unwrap_or_default(); + let source = with_thread_spawn_agent_metadata( + thread.source, + thread.agent_nickname.clone(), + thread.agent_role.clone(), + ); + let git_info = thread.git_info.map(|git| ConversationGitInfo { + sha: git.commit_hash.map(|sha| sha.0), + branch: git.branch, + origin_url: git.repository_url, + }); + ConversationSummary { + conversation_id: thread.thread_id, + path, + preview: thread.preview, + // Preserve millisecond precision from the thread store so thread/list cursors + // round-trip the same ordering key used by pagination queries. + timestamp: Some( + thread + .created_at + .to_rfc3339_opts(SecondsFormat::Millis, true), + ), + updated_at: Some( + thread + .updated_at + .to_rfc3339_opts(SecondsFormat::Millis, true), + ), + model_provider: if thread.model_provider.is_empty() { + fallback_provider.to_string() + } else { + thread.model_provider + }, + cwd: thread.cwd, + cli_version: thread.cli_version, + source, + git_info, + } +} + +#[allow(clippy::too_many_arguments)] +#[cfg(test)] +fn summary_from_state_db_metadata( + conversation_id: ThreadId, + path: PathBuf, + first_user_message: Option, + preview: Option, + timestamp: String, + updated_at: String, + model_provider: String, + cwd: PathBuf, + cli_version: String, + source: String, + _thread_source: Option, + agent_nickname: Option, + agent_role: Option, + git_sha: Option, + git_branch: Option, + git_origin_url: Option, +) -> ConversationSummary { + let preview = preview.or(first_user_message).unwrap_or_default(); + let source = serde_json::from_str(&source) + .or_else(|_| serde_json::from_value(serde_json::Value::String(source.clone()))) + .unwrap_or(codex_protocol::protocol::SessionSource::Unknown); + let source = with_thread_spawn_agent_metadata(source, agent_nickname, agent_role); + let git_info = if git_sha.is_none() && git_branch.is_none() && git_origin_url.is_none() { + None + } else { + Some(ConversationGitInfo { + sha: git_sha, + branch: git_branch, + origin_url: git_origin_url, + }) + }; + ConversationSummary { + conversation_id, + path, + preview, + timestamp: Some(timestamp), + updated_at: Some(updated_at), + model_provider, + cwd, + cli_version, + source, + git_info, + } +} + +#[cfg(test)] +fn summary_from_thread_metadata(metadata: &ThreadMetadata) -> ConversationSummary { + summary_from_state_db_metadata( + metadata.id, + metadata.rollout_path.clone(), + metadata.first_user_message.clone(), + metadata.preview.clone(), + metadata + .created_at + .to_rfc3339_opts(SecondsFormat::Secs, true), + metadata + .updated_at + .to_rfc3339_opts(SecondsFormat::Secs, true), + metadata.model_provider.clone(), + metadata.cwd.clone(), + metadata.cli_version.clone(), + metadata.source.clone(), + metadata.thread_source, + metadata.agent_nickname.clone(), + metadata.agent_role.clone(), + metadata.git_sha.clone(), + metadata.git_branch.clone(), + metadata.git_origin_url.clone(), + ) +} + +fn preview_from_rollout_items(items: &[RolloutItem]) -> String { + items + .iter() + .find_map(|item| match item { + RolloutItem::ResponseItem(item) => match codex_core::parse_turn_item(item) { + Some(codex_protocol::items::TurnItem::UserMessage(user)) => Some(user.message()), + _ => None, + }, + _ => None, + }) + .map(|preview| match preview.find(USER_MESSAGE_BEGIN) { + Some(idx) => preview[idx + USER_MESSAGE_BEGIN.len()..].trim().to_string(), + None => preview, + }) + .unwrap_or_default() +} + +fn requested_permissions_trust_project(overrides: &ConfigOverrides, cwd: &Path) -> bool { + if matches!( + overrides.sandbox_mode, + Some( + codex_protocol::config_types::SandboxMode::WorkspaceWrite + | codex_protocol::config_types::SandboxMode::DangerFullAccess + ) + ) { + return true; + } + + if matches!( + overrides.default_permissions.as_deref(), + Some(":workspace" | ":danger-no-sandbox") + ) { + return true; + } + + overrides + .permission_profile + .as_ref() + .is_some_and(|profile| permission_profile_trusts_project(profile, cwd)) +} + +fn permission_profile_trusts_project( + profile: &codex_protocol::models::PermissionProfile, + cwd: &Path, +) -> bool { + match profile { + codex_protocol::models::PermissionProfile::Disabled + | codex_protocol::models::PermissionProfile::External { .. } => true, + codex_protocol::models::PermissionProfile::Managed { .. } => profile + .file_system_sandbox_policy() + .can_write_path_with_cwd(cwd, cwd), + } +} + +fn build_thread_from_snapshot( + thread_id: ThreadId, + session_id: String, + config_snapshot: &ThreadConfigSnapshot, + path: Option, +) -> Thread { + let now = time::OffsetDateTime::now_utc().unix_timestamp(); + Thread { + id: thread_id.to_string(), + session_id, + forked_from_id: None, + preview: String::new(), + ephemeral: config_snapshot.ephemeral, + model_provider: config_snapshot.model_provider_id.clone(), + created_at: now, + updated_at: now, + status: ThreadStatus::NotLoaded, + path, + cwd: config_snapshot.cwd.clone(), + cli_version: env!("CARGO_PKG_VERSION").to_string(), + agent_nickname: config_snapshot.session_source.get_nickname(), + agent_role: config_snapshot.session_source.get_agent_role(), + source: config_snapshot.session_source.clone().into(), + thread_source: config_snapshot.thread_source.map(Into::into), + git_info: None, + name: None, + turns: Vec::new(), + } +} + +fn build_thread_from_loaded_snapshot( + thread_id: ThreadId, + config_snapshot: &ThreadConfigSnapshot, + loaded_thread: &CodexThread, +) -> Thread { + build_thread_from_snapshot( + thread_id, + loaded_thread.session_configured().session_id.to_string(), + config_snapshot, + loaded_thread.rollout_path(), + ) +} + +#[cfg(test)] +#[path = "thread_processor_tests.rs"] +mod thread_processor_tests; diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs new file mode 100644 index 0000000000..61a31f9c4d --- /dev/null +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -0,0 +1,1355 @@ +mod thread_list_cwd_filter_tests { + use super::super::normalize_thread_list_cwd_filters; + use codex_app_server_protocol::ThreadListCwdFilter; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + #[test] + fn normalize_thread_list_cwd_filter_preserves_absolute_paths() { + let cwd = if cfg!(windows) { + String::from(r"C:\srv\repo-b") + } else { + String::from("/srv/repo-b") + }; + + assert_eq!( + normalize_thread_list_cwd_filters(Some(ThreadListCwdFilter::One(cwd.clone()))) + .expect("cwd filter should parse"), + Some(vec![PathBuf::from(cwd)]) + ); + } + + #[test] + fn normalize_thread_list_cwd_filter_resolves_relative_paths_against_server_cwd() + -> std::io::Result<()> { + let expected = AbsolutePathBuf::relative_to_current_dir("repo-b")?.to_path_buf(); + + assert_eq!( + normalize_thread_list_cwd_filters(Some(ThreadListCwdFilter::Many(vec![String::from( + "repo-b" + ),]))) + .expect("cwd filter should parse"), + Some(vec![expected]) + ); + Ok(()) + } +} + +mod thread_processor_behavior_tests { + async fn forked_from_id_from_rollout(path: &Path) -> Option { + codex_core::read_session_meta_line(path) + .await + .ok() + .and_then(|meta_line| meta_line.meta.forked_from_id) + .map(|thread_id| thread_id.to_string()) + } + + use super::super::*; + use crate::outgoing_message::OutgoingEnvelope; + use crate::outgoing_message::OutgoingMessage; + use anyhow::Result; + use chrono::DateTime; + use chrono::Utc; + use codex_app_server_protocol::ServerRequestPayload; + use codex_app_server_protocol::ThreadItem; + use codex_app_server_protocol::ToolRequestUserInputParams; + use codex_config::CloudRequirementsLoader; + use codex_config::LoaderOverrides; + use codex_config::SessionThreadConfig; + use codex_config::StaticThreadConfigLoader; + use codex_config::ThreadConfigSource; + use codex_model_provider_info::ModelProviderInfo; + use codex_model_provider_info::WireApi; + use codex_protocol::ThreadId; + use codex_protocol::openai_models::ReasoningEffort; + use codex_protocol::permissions::FileSystemAccessMode; + use codex_protocol::permissions::FileSystemPath; + use codex_protocol::permissions::FileSystemSandboxEntry; + use codex_protocol::permissions::NetworkSandboxPolicy; + use codex_protocol::protocol::AskForApproval; + use codex_protocol::protocol::SandboxPolicy; + use codex_protocol::protocol::SessionSource; + use codex_protocol::protocol::SubAgentSource; + use codex_state::ThreadMetadataBuilder; + use codex_thread_store::StoredThread; + use codex_utils_absolute_path::test_support::PathBufExt; + use codex_utils_absolute_path::test_support::test_path_buf; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::collections::BTreeMap; + use std::path::PathBuf; + use std::sync::Arc; + use tempfile::TempDir; + + #[test] + fn validate_dynamic_tools_rejects_unsupported_input_schema() { + let tools = vec![ApiDynamicToolSpec { + namespace: None, + name: "my_tool".to_string(), + description: "test".to_string(), + input_schema: json!({"type": "null"}), + defer_loading: false, + }]; + let err = validate_dynamic_tools(&tools).expect_err("invalid schema"); + assert!(err.contains("my_tool"), "unexpected error: {err}"); + } + + #[test] + fn validate_dynamic_tools_accepts_sanitizable_input_schema() { + let tools = vec![ApiDynamicToolSpec { + namespace: None, + name: "my_tool".to_string(), + description: "test".to_string(), + // Missing `type` is common; core sanitizes these to a supported schema. + input_schema: json!({"properties": {}}), + defer_loading: false, + }]; + validate_dynamic_tools(&tools).expect("valid schema"); + } + + #[test] + fn validate_dynamic_tools_accepts_nullable_field_schema() { + let tools = vec![ApiDynamicToolSpec { + namespace: None, + name: "my_tool".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": { + "query": {"type": ["string", "null"]} + }, + "required": ["query"], + "additionalProperties": false + }), + defer_loading: false, + }]; + validate_dynamic_tools(&tools).expect("valid schema"); + } + + #[test] + fn validate_dynamic_tools_accepts_same_name_in_different_namespaces() { + let tools = vec![ + ApiDynamicToolSpec { + namespace: Some("codex_app".to_string()), + name: "my_tool".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }, + ApiDynamicToolSpec { + namespace: Some("other_app".to_string()), + name: "my_tool".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }, + ]; + validate_dynamic_tools(&tools).expect("valid schema"); + } + + #[test] + fn validate_dynamic_tools_accepts_responses_compatible_identifiers() { + let tools = vec![ApiDynamicToolSpec { + namespace: Some("Codex-App_2".to_string()), + name: "lookup-ticket_2".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + validate_dynamic_tools(&tools).expect("valid schema"); + } + + #[test] + fn validate_dynamic_tools_rejects_duplicate_name_in_same_namespace() { + let tools = vec![ + ApiDynamicToolSpec { + namespace: Some("codex_app".to_string()), + name: "my_tool".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }, + ApiDynamicToolSpec { + namespace: Some("codex_app".to_string()), + name: "my_tool".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }, + ]; + let err = validate_dynamic_tools(&tools).expect_err("duplicate name"); + assert!(err.contains("codex_app"), "unexpected error: {err}"); + assert!(err.contains("my_tool"), "unexpected error: {err}"); + } + + #[test] + fn thread_turns_list_merges_in_progress_active_turn_before_agent_status_running() { + let persisted_items = vec![RolloutItem::EventMsg(EventMsg::UserMessage( + codex_protocol::protocol::UserMessageEvent { + message: "persisted".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + }, + ))]; + let active_turn = Turn { + id: "live-turn".to_string(), + items: vec![ThreadItem::UserMessage { + id: "live-user-message".to_string(), + content: vec![V2UserInput::Text { + text: "live".to_string(), + text_elements: Vec::new(), + }], + }], + items_view: TurnItemsView::Full, + error: None, + status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, + }; + + let turns = reconstruct_thread_turns_for_turns_list( + &persisted_items, + ThreadStatus::Idle, + /*has_live_running_thread*/ false, + Some(active_turn.clone()), + ); + + assert_eq!(turns.last(), Some(&active_turn)); + } + + #[test] + fn validate_dynamic_tools_rejects_empty_namespace() { + let tools = vec![ApiDynamicToolSpec { + namespace: Some("".to_string()), + name: "my_tool".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: false, + }]; + let err = validate_dynamic_tools(&tools).expect_err("empty namespace"); + assert!(err.contains("my_tool"), "unexpected error: {err}"); + assert!(err.contains("namespace"), "unexpected error: {err}"); + } + + #[test] + fn validate_dynamic_tools_rejects_reserved_namespace() { + let tools = vec![ApiDynamicToolSpec { + namespace: Some("mcp__server__".to_string()), + name: "my_tool".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: false, + }]; + let err = validate_dynamic_tools(&tools).expect_err("reserved namespace"); + assert!(err.contains("my_tool"), "unexpected error: {err}"); + assert!(err.contains("reserved"), "unexpected error: {err}"); + } + + #[test] + fn validate_dynamic_tools_rejects_name_not_supported_by_responses() { + let tools = vec![ApiDynamicToolSpec { + namespace: None, + name: "lookup.ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: false, + }]; + let err = validate_dynamic_tools(&tools).expect_err("invalid name"); + assert!(err.contains("lookup.ticket"), "unexpected error: {err}"); + assert!( + err.contains("Responses API") && err.contains("^[a-zA-Z0-9_-]+$"), + "unexpected error: {err}" + ); + } + + #[test] + fn validate_dynamic_tools_rejects_namespace_not_supported_by_responses() { + let tools = vec![ApiDynamicToolSpec { + namespace: Some("codex.app".to_string()), + name: "lookup_ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + let err = validate_dynamic_tools(&tools).expect_err("invalid namespace"); + assert!(err.contains("codex.app"), "unexpected error: {err}"); + assert!( + err.contains("Responses API") && err.contains("^[a-zA-Z0-9_-]+$"), + "unexpected error: {err}" + ); + } + + #[test] + fn validate_dynamic_tools_rejects_name_longer_than_responses_limit() { + let long_name = "a".repeat(129); + let tools = vec![ApiDynamicToolSpec { + namespace: None, + name: long_name.clone(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: false, + }]; + let err = validate_dynamic_tools(&tools).expect_err("name too long"); + assert!(err.contains("at most 128"), "unexpected error: {err}"); + assert!(err.contains(&long_name), "unexpected error: {err}"); + } + + #[test] + fn validate_dynamic_tools_rejects_namespace_longer_than_responses_limit() { + let long_namespace = "a".repeat(65); + let tools = vec![ApiDynamicToolSpec { + namespace: Some(long_namespace.clone()), + name: "lookup_ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + let err = validate_dynamic_tools(&tools).expect_err("namespace too long"); + assert!(err.contains("at most 64"), "unexpected error: {err}"); + assert!(err.contains(&long_namespace), "unexpected error: {err}"); + } + + #[test] + fn validate_dynamic_tools_rejects_reserved_responses_namespace() { + let tools = vec![ApiDynamicToolSpec { + namespace: Some("functions".to_string()), + name: "lookup_ticket".to_string(), + description: "test".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }), + defer_loading: true, + }]; + let err = validate_dynamic_tools(&tools).expect_err("reserved Responses namespace"); + assert!(err.contains("functions"), "unexpected error: {err}"); + assert!(err.contains("Responses API"), "unexpected error: {err}"); + } + + #[test] + fn summary_from_stored_thread_preserves_millisecond_precision() { + let created_at = + DateTime::parse_from_rfc3339("2025-01-02T03:04:05.678Z").expect("valid timestamp"); + let updated_at = + DateTime::parse_from_rfc3339("2025-01-02T03:04:06.789Z").expect("valid timestamp"); + let thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread"); + let stored_thread = StoredThread { + thread_id, + rollout_path: Some(PathBuf::from("/tmp/thread.jsonl")), + forked_from_id: None, + preview: "preview".to_string(), + name: None, + model_provider: "openai".to_string(), + model: None, + reasoning_effort: None, + created_at: created_at.with_timezone(&Utc), + updated_at: updated_at.with_timezone(&Utc), + archived_at: None, + cwd: PathBuf::from("/tmp"), + cli_version: "0.0.0".to_string(), + source: SessionSource::Cli, + thread_source: Some(codex_protocol::protocol::ThreadSource::User), + agent_nickname: None, + agent_role: None, + agent_path: None, + git_info: None, + approval_mode: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + token_usage: None, + first_user_message: Some("first user message".to_string()), + history: None, + }; + + let summary = summary_from_stored_thread(stored_thread, "fallback"); + + assert_eq!( + summary.timestamp.as_deref(), + Some("2025-01-02T03:04:05.678Z") + ); + assert_eq!( + summary.updated_at.as_deref(), + Some("2025-01-02T03:04:06.789Z") + ); + } + + #[test] + fn requested_permissions_trust_project_uses_permission_profile_intent() { + let cwd = test_path_buf("/tmp/project").abs(); + let full_access_profile = codex_protocol::models::PermissionProfile::Disabled; + let workspace_write_profile = codex_protocol::models::PermissionProfile::workspace_write(); + let read_only_profile = codex_protocol::models::PermissionProfile::read_only(); + let split_write_profile = + codex_protocol::models::PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Path { path: cwd.clone() }, + access: FileSystemAccessMode::Write, + }, + FileSystemSandboxEntry { + path: FileSystemPath::GlobPattern { + pattern: "/tmp/project/**/*.env".to_string(), + }, + access: FileSystemAccessMode::None, + }, + ]), + NetworkSandboxPolicy::Restricted, + ); + + assert!(requested_permissions_trust_project( + &ConfigOverrides { + permission_profile: Some(full_access_profile), + ..Default::default() + }, + cwd.as_path() + )); + assert!(requested_permissions_trust_project( + &ConfigOverrides { + permission_profile: Some(workspace_write_profile), + ..Default::default() + }, + cwd.as_path() + )); + assert!(requested_permissions_trust_project( + &ConfigOverrides { + permission_profile: Some(split_write_profile), + ..Default::default() + }, + cwd.as_path() + )); + assert!(requested_permissions_trust_project( + &ConfigOverrides { + default_permissions: Some(":workspace".to_string()), + ..Default::default() + }, + cwd.as_path() + )); + assert!(requested_permissions_trust_project( + &ConfigOverrides { + default_permissions: Some(":danger-no-sandbox".to_string()), + ..Default::default() + }, + cwd.as_path() + )); + assert!(!requested_permissions_trust_project( + &ConfigOverrides { + permission_profile: Some(read_only_profile), + ..Default::default() + }, + cwd.as_path() + )); + assert!(!requested_permissions_trust_project( + &ConfigOverrides { + default_permissions: Some(":read-only".to_string()), + ..Default::default() + }, + cwd.as_path() + )); + } + + #[test] + fn config_load_error_marks_cloud_requirements_failures_for_relogin() { + let err = std::io::Error::other(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::Auth, + Some(401), + "Your authentication session could not be refreshed automatically. Please log out and sign in again.", + )); + + let error = config_load_error(&err); + + assert_eq!( + error.data, + Some(json!({ + "reason": "cloudRequirements", + "errorCode": "Auth", + "action": "relogin", + "statusCode": 401, + "detail": "Your authentication session could not be refreshed automatically. Please log out and sign in again.", + })) + ); + assert!( + error.message.contains("failed to load configuration"), + "unexpected error message: {}", + error.message + ); + } + + #[test] + fn config_load_error_leaves_non_cloud_requirements_failures_unmarked() { + let err = std::io::Error::other("required MCP servers failed to initialize"); + + let error = config_load_error(&err); + + assert_eq!(error.data, None); + assert!( + error.message.contains("failed to load configuration"), + "unexpected error message: {}", + error.message + ); + } + + #[test] + fn config_load_error_marks_non_auth_cloud_requirements_failures_without_relogin() { + let err = std::io::Error::other(CloudRequirementsLoadError::new( + CloudRequirementsLoadErrorCode::RequestFailed, + /*status_code*/ None, + "Failed to load cloud requirements (workspace-managed policies).", + )); + + let error = config_load_error(&err); + + assert_eq!( + error.data, + Some(json!({ + "reason": "cloudRequirements", + "errorCode": "RequestFailed", + "detail": "Failed to load cloud requirements (workspace-managed policies).", + })) + ); + } + + #[tokio::test] + async fn derive_config_from_params_uses_session_thread_config_model_provider() -> Result<()> { + let temp_dir = TempDir::new()?; + let session_provider = ModelProviderInfo { + name: "session".to_string(), + base_url: Some("http://127.0.0.1:8061/api/codex".to_string()), + env_key: None, + env_key_instructions: None, + experimental_bearer_token: None, + auth: None, + aws: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: None, + stream_max_retries: None, + stream_idle_timeout_ms: None, + websocket_connect_timeout_ms: None, + requires_openai_auth: false, + supports_websockets: true, + }; + let config_manager = ConfigManager::new( + temp_dir.path().to_path_buf(), + Vec::new(), + LoaderOverrides::default(), + CloudRequirementsLoader::default(), + Arg0DispatchPaths::default(), + Arc::new(StaticThreadConfigLoader::new(vec![ + ThreadConfigSource::Session(SessionThreadConfig { + model_provider: Some("session".to_string()), + model_providers: HashMap::from([( + "session".to_string(), + session_provider.clone(), + )]), + features: BTreeMap::from([("plugins".to_string(), false)]), + }), + ])), + ); + let config = config_manager + .load_with_overrides( + Some(HashMap::from([ + ("model_provider".to_string(), json!("request")), + ("features.plugins".to_string(), json!(true)), + ( + "model_providers.session".to_string(), + json!({ + "name": "request", + "base_url": "http://127.0.0.1:9999/api/codex", + "wire_api": "responses", + }), + ), + ])), + ConfigOverrides::default(), + ) + .await?; + + assert_eq!(config.model_provider_id, "session"); + assert_eq!(config.model_provider, session_provider); + assert!(!config.features.enabled(Feature::Plugins)); + Ok(()) + } + + #[test] + fn collect_resume_override_mismatches_includes_service_tier() { + let cwd = test_path_buf("/tmp").abs(); + let request = ThreadResumeParams { + thread_id: "thread-1".to_string(), + history: None, + path: None, + model: None, + model_provider: None, + service_tier: Some(Some("priority".to_string())), + cwd: None, + approval_policy: None, + approvals_reviewer: None, + sandbox: None, + permissions: None, + config: None, + base_instructions: None, + developer_instructions: None, + personality: None, + exclude_turns: false, + persist_extended_history: false, + }; + let config_snapshot = ThreadConfigSnapshot { + model: "gpt-5".to_string(), + model_provider_id: "openai".to_string(), + service_tier: Some("flex".to_string()), + approval_policy: codex_protocol::protocol::AskForApproval::OnRequest, + approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, + permission_profile: codex_protocol::models::PermissionProfile::Disabled, + active_permission_profile: None, + cwd, + ephemeral: false, + reasoning_effort: None, + personality: None, + session_source: SessionSource::Cli, + thread_source: None, + }; + + assert_eq!( + collect_resume_override_mismatches(&request, &config_snapshot), + vec!["service_tier requested=Some(\"priority\") active=Some(\"flex\")".to_string()] + ); + } + + fn test_thread_metadata( + model: Option<&str>, + reasoning_effort: Option, + ) -> Result { + let thread_id = ThreadId::from_string("3f941c35-29b3-493b-b0a4-e25800d9aeb0")?; + let mut builder = ThreadMetadataBuilder::new( + thread_id, + PathBuf::from("/tmp/rollout.jsonl"), + Utc::now(), + codex_protocol::protocol::SessionSource::default(), + ); + builder.model_provider = Some("mock_provider".to_string()); + let mut metadata = builder.build("mock_provider"); + metadata.model = model.map(ToString::to_string); + metadata.reasoning_effort = reasoning_effort; + Ok(metadata) + } + + #[test] + fn summary_from_thread_metadata_formats_protocol_timestamps_as_seconds() -> Result<()> { + let mut metadata = + test_thread_metadata(/*model*/ None, /*reasoning_effort*/ None)?; + metadata.created_at = + DateTime::parse_from_rfc3339("2025-09-05T16:53:11.123Z")?.with_timezone(&Utc); + metadata.updated_at = + DateTime::parse_from_rfc3339("2025-09-05T16:53:12.456Z")?.with_timezone(&Utc); + + let summary = summary_from_thread_metadata(&metadata); + + assert_eq!(summary.timestamp, Some("2025-09-05T16:53:11Z".to_string())); + assert_eq!(summary.updated_at, Some("2025-09-05T16:53:12Z".to_string())); + Ok(()) + } + + #[test] + fn merge_persisted_resume_metadata_prefers_persisted_model_and_reasoning_effort() -> Result<()> + { + let mut request_overrides = None; + let mut typesafe_overrides = ConfigOverrides::default(); + let persisted_metadata = + test_thread_metadata(Some("gpt-5.1-codex-max"), Some(ReasoningEffort::High))?; + + merge_persisted_resume_metadata( + &mut request_overrides, + &mut typesafe_overrides, + &persisted_metadata, + ); + + assert_eq!( + typesafe_overrides.model, + Some("gpt-5.1-codex-max".to_string()) + ); + assert_eq!( + typesafe_overrides.model_provider, + Some("mock_provider".to_string()) + ); + assert_eq!( + request_overrides, + Some(HashMap::from([( + "model_reasoning_effort".to_string(), + serde_json::Value::String("high".to_string()), + )])) + ); + Ok(()) + } + + #[test] + fn merge_persisted_resume_metadata_preserves_explicit_overrides() -> Result<()> { + let mut request_overrides = Some(HashMap::from([( + "model_reasoning_effort".to_string(), + serde_json::Value::String("low".to_string()), + )])); + let mut typesafe_overrides = ConfigOverrides { + model: Some("gpt-5.2-codex".to_string()), + ..Default::default() + }; + let persisted_metadata = + test_thread_metadata(Some("gpt-5.1-codex-max"), Some(ReasoningEffort::High))?; + + merge_persisted_resume_metadata( + &mut request_overrides, + &mut typesafe_overrides, + &persisted_metadata, + ); + + assert_eq!(typesafe_overrides.model, Some("gpt-5.2-codex".to_string())); + assert_eq!(typesafe_overrides.model_provider, None); + assert_eq!( + request_overrides, + Some(HashMap::from([( + "model_reasoning_effort".to_string(), + serde_json::Value::String("low".to_string()), + )])) + ); + Ok(()) + } + + #[test] + fn merge_persisted_resume_metadata_skips_persisted_values_when_model_overridden() -> Result<()> + { + let mut request_overrides = Some(HashMap::from([( + "model".to_string(), + serde_json::Value::String("gpt-5.2-codex".to_string()), + )])); + let mut typesafe_overrides = ConfigOverrides::default(); + let persisted_metadata = + test_thread_metadata(Some("gpt-5.1-codex-max"), Some(ReasoningEffort::High))?; + + merge_persisted_resume_metadata( + &mut request_overrides, + &mut typesafe_overrides, + &persisted_metadata, + ); + + assert_eq!(typesafe_overrides.model, None); + assert_eq!(typesafe_overrides.model_provider, None); + assert_eq!( + request_overrides, + Some(HashMap::from([( + "model".to_string(), + serde_json::Value::String("gpt-5.2-codex".to_string()), + )])) + ); + Ok(()) + } + + #[test] + fn merge_persisted_resume_metadata_skips_persisted_values_when_provider_overridden() + -> Result<()> { + let mut request_overrides = None; + let mut typesafe_overrides = ConfigOverrides { + model_provider: Some("oss".to_string()), + ..Default::default() + }; + let persisted_metadata = + test_thread_metadata(Some("gpt-5.1-codex-max"), Some(ReasoningEffort::High))?; + + merge_persisted_resume_metadata( + &mut request_overrides, + &mut typesafe_overrides, + &persisted_metadata, + ); + + assert_eq!(typesafe_overrides.model, None); + assert_eq!(typesafe_overrides.model_provider, Some("oss".to_string())); + assert_eq!(request_overrides, None); + Ok(()) + } + + #[test] + fn merge_persisted_resume_metadata_skips_persisted_values_when_reasoning_effort_overridden() + -> Result<()> { + let mut request_overrides = Some(HashMap::from([( + "model_reasoning_effort".to_string(), + serde_json::Value::String("low".to_string()), + )])); + let mut typesafe_overrides = ConfigOverrides::default(); + let persisted_metadata = + test_thread_metadata(Some("gpt-5.1-codex-max"), Some(ReasoningEffort::High))?; + + merge_persisted_resume_metadata( + &mut request_overrides, + &mut typesafe_overrides, + &persisted_metadata, + ); + + assert_eq!(typesafe_overrides.model, None); + assert_eq!(typesafe_overrides.model_provider, None); + assert_eq!( + request_overrides, + Some(HashMap::from([( + "model_reasoning_effort".to_string(), + serde_json::Value::String("low".to_string()), + )])) + ); + Ok(()) + } + + #[test] + fn merge_persisted_resume_metadata_skips_missing_values() -> Result<()> { + let mut request_overrides = None; + let mut typesafe_overrides = ConfigOverrides::default(); + let persisted_metadata = + test_thread_metadata(/*model*/ None, /*reasoning_effort*/ None)?; + + merge_persisted_resume_metadata( + &mut request_overrides, + &mut typesafe_overrides, + &persisted_metadata, + ); + + assert_eq!(typesafe_overrides.model, None); + assert_eq!( + typesafe_overrides.model_provider, + Some("mock_provider".to_string()) + ); + assert_eq!(request_overrides, None); + Ok(()) + } + + #[tokio::test] + async fn read_summary_from_rollout_returns_empty_preview_when_no_user_message() -> Result<()> { + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::RolloutLine; + use codex_protocol::protocol::SessionMetaLine; + use std::fs; + use std::fs::FileTimes; + + let temp_dir = TempDir::new()?; + let path = temp_dir.path().join("rollout.jsonl"); + + let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; + let timestamp = "2025-09-05T16:53:11.850Z".to_string(); + + let session_meta = SessionMeta { + id: conversation_id, + timestamp: timestamp.clone(), + model_provider: None, + ..SessionMeta::default() + }; + + let line = RolloutLine { + timestamp: timestamp.clone(), + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: session_meta.clone(), + git: None, + }), + }; + + fs::write(&path, format!("{}\n", serde_json::to_string(&line)?))?; + let parsed = chrono::DateTime::parse_from_rfc3339(×tamp)?.with_timezone(&Utc); + let times = FileTimes::new().set_modified(parsed.into()); + std::fs::OpenOptions::new() + .append(true) + .open(&path)? + .set_times(times)?; + + let summary = read_summary_from_rollout(path.as_path(), "fallback").await?; + + let expected = ConversationSummary { + conversation_id, + timestamp: Some(timestamp.clone()), + updated_at: Some(timestamp), + path: path.clone(), + preview: String::new(), + model_provider: "fallback".to_string(), + cwd: PathBuf::new(), + cli_version: String::new(), + source: SessionSource::VSCode, + git_info: None, + }; + + assert_eq!(summary, expected); + Ok(()) + } + + #[tokio::test] + async fn read_summary_from_rollout_preserves_agent_nickname() -> Result<()> { + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::RolloutLine; + use codex_protocol::protocol::SessionMetaLine; + use std::fs; + + let temp_dir = TempDir::new()?; + let path = temp_dir.path().join("rollout.jsonl"); + + let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; + let parent_thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; + let timestamp = "2025-09-05T16:53:11.850Z".to_string(); + + let session_meta = SessionMeta { + id: conversation_id, + timestamp: timestamp.clone(), + source: SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }), + thread_source: Some(codex_protocol::protocol::ThreadSource::Subagent), + agent_nickname: Some("atlas".to_string()), + agent_role: Some("explorer".to_string()), + model_provider: Some("test-provider".to_string()), + ..SessionMeta::default() + }; + + let line = RolloutLine { + timestamp, + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: session_meta, + git: None, + }), + }; + fs::write(&path, format!("{}\n", serde_json::to_string(&line)?))?; + + let summary = read_summary_from_rollout(path.as_path(), "fallback").await?; + let fallback_cwd = AbsolutePathBuf::from_absolute_path("/")?; + let thread = summary_to_thread(summary, &fallback_cwd); + + assert_eq!(thread.agent_nickname, Some("atlas".to_string())); + assert_eq!(thread.agent_role, Some("explorer".to_string())); + assert_eq!(thread.thread_source, None); + Ok(()) + } + + #[tokio::test] + async fn read_summary_from_rollout_preserves_forked_from_id() -> Result<()> { + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::RolloutLine; + use codex_protocol::protocol::SessionMetaLine; + use std::fs; + + let temp_dir = TempDir::new()?; + let path = temp_dir.path().join("rollout.jsonl"); + + let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; + let forked_from_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; + let timestamp = "2025-09-05T16:53:11.850Z".to_string(); + + let session_meta = SessionMeta { + id: conversation_id, + forked_from_id: Some(forked_from_id), + timestamp: timestamp.clone(), + model_provider: Some("test-provider".to_string()), + ..SessionMeta::default() + }; + + let line = RolloutLine { + timestamp, + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: session_meta, + git: None, + }), + }; + fs::write(&path, format!("{}\n", serde_json::to_string(&line)?))?; + + assert_eq!( + forked_from_id_from_rollout(path.as_path()).await, + Some(forked_from_id.to_string()) + ); + Ok(()) + } + + #[tokio::test] + async fn aborting_pending_request_clears_pending_state() -> Result<()> { + let thread_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; + let connection_id = ConnectionId(7); + + let (outgoing_tx, mut outgoing_rx) = tokio::sync::mpsc::channel(8); + let outgoing = Arc::new(OutgoingMessageSender::new( + outgoing_tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )); + let thread_outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing.clone(), + vec![connection_id], + thread_id, + ); + + let (request_id, client_request_rx) = thread_outgoing + .send_request(ServerRequestPayload::ToolRequestUserInput( + ToolRequestUserInputParams { + thread_id: thread_id.to_string(), + turn_id: "turn-1".to_string(), + item_id: "call-1".to_string(), + questions: vec![], + }, + )) + .await; + thread_outgoing.abort_pending_server_requests().await; + + let request_message = outgoing_rx.recv().await.expect("request should be sent"); + let OutgoingEnvelope::ToConnection { + connection_id: request_connection_id, + message: + OutgoingMessage::Request(ServerRequest::ToolRequestUserInput { + request_id: sent_request_id, + .. + }), + .. + } = request_message + else { + panic!("expected tool request to be sent to the subscribed connection"); + }; + assert_eq!(request_connection_id, connection_id); + assert_eq!(sent_request_id, request_id); + + let response = client_request_rx + .await + .expect("callback should be resolved"); + let error = response.expect_err("request should be aborted during cleanup"); + assert_eq!( + error.message, + "client request resolved because the turn state was changed" + ); + assert_eq!(error.data, Some(json!({ "reason": "turnTransition" }))); + assert!( + outgoing + .pending_requests_for_thread(thread_id) + .await + .is_empty() + ); + assert!(outgoing_rx.try_recv().is_err()); + Ok(()) + } + + #[test] + fn summary_from_state_db_metadata_preserves_agent_nickname() -> Result<()> { + let conversation_id = ThreadId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; + let source = + serde_json::to_string(&SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?, + depth: 1, + agent_path: None, + agent_nickname: None, + agent_role: None, + }))?; + + let summary = summary_from_state_db_metadata( + conversation_id, + PathBuf::from("/tmp/rollout.jsonl"), + Some("hi".to_string()), + /*preview*/ None, + "2025-09-05T16:53:11Z".to_string(), + "2025-09-05T16:53:12Z".to_string(), + "test-provider".to_string(), + PathBuf::from("/"), + "0.0.0".to_string(), + source, + Some(codex_protocol::protocol::ThreadSource::Subagent), + Some("atlas".to_string()), + Some("explorer".to_string()), + /*git_sha*/ None, + /*git_branch*/ None, + /*git_origin_url*/ None, + ); + + let fallback_cwd = AbsolutePathBuf::from_absolute_path("/")?; + let thread = summary_to_thread(summary, &fallback_cwd); + + assert_eq!(thread.agent_nickname, Some("atlas".to_string())); + assert_eq!(thread.agent_role, Some("explorer".to_string())); + Ok(()) + } + + #[tokio::test] + async fn removing_thread_state_clears_listener_and_active_turn_history() -> Result<()> { + let manager = ThreadStateManager::new(); + let thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; + let connection = ConnectionId(1); + let (cancel_tx, cancel_rx) = oneshot::channel(); + + manager + .connection_initialized(connection, ConnectionCapabilities::default()) + .await; + manager + .try_ensure_connection_subscribed( + thread_id, connection, /*experimental_raw_events*/ false, + ) + .await + .expect("connection should be live"); + { + let state = manager.thread_state(thread_id).await; + let mut state = state.lock().await; + state.cancel_tx = Some(cancel_tx); + state.track_current_turn_event( + "turn-1", + &EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent { + turn_id: "turn-1".to_string(), + started_at: None, + model_context_window: None, + collaboration_mode_kind: Default::default(), + }), + ); + } + + manager.remove_thread_state(thread_id).await; + assert_eq!(cancel_rx.await, Ok(())); + + let state = manager.thread_state(thread_id).await; + let subscribed_connection_ids = manager.subscribed_connection_ids(thread_id).await; + assert!(subscribed_connection_ids.is_empty()); + let state = state.lock().await; + assert!(state.cancel_tx.is_none()); + assert!(state.active_turn_snapshot().is_none()); + Ok(()) + } + + #[tokio::test] + async fn removing_auto_attached_connection_preserves_listener_for_other_connections() + -> Result<()> { + let manager = ThreadStateManager::new(); + let thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; + let connection_a = ConnectionId(1); + let connection_b = ConnectionId(2); + let (cancel_tx, mut cancel_rx) = oneshot::channel(); + + manager + .connection_initialized(connection_a, ConnectionCapabilities::default()) + .await; + manager + .connection_initialized(connection_b, ConnectionCapabilities::default()) + .await; + manager + .try_ensure_connection_subscribed( + thread_id, + connection_a, + /*experimental_raw_events*/ false, + ) + .await + .expect("connection_a should be live"); + manager + .try_ensure_connection_subscribed( + thread_id, + connection_b, + /*experimental_raw_events*/ false, + ) + .await + .expect("connection_b should be live"); + { + let state = manager.thread_state(thread_id).await; + state.lock().await.cancel_tx = Some(cancel_tx); + } + + let threads_to_unload = manager.remove_connection(connection_a).await; + assert_eq!(threads_to_unload, Vec::::new()); + assert!( + tokio::time::timeout(Duration::from_millis(20), &mut cancel_rx) + .await + .is_err() + ); + + assert_eq!( + manager.subscribed_connection_ids(thread_id).await, + vec![connection_b] + ); + Ok(()) + } + + #[tokio::test] + async fn adding_connection_to_thread_updates_has_connections_watcher() -> Result<()> { + let manager = ThreadStateManager::new(); + let thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; + let connection_a = ConnectionId(1); + let connection_b = ConnectionId(2); + + manager + .connection_initialized(connection_a, ConnectionCapabilities::default()) + .await; + manager + .connection_initialized(connection_b, ConnectionCapabilities::default()) + .await; + manager + .try_ensure_connection_subscribed( + thread_id, + connection_a, + /*experimental_raw_events*/ false, + ) + .await + .expect("connection_a should be live"); + let mut has_connections = manager + .subscribe_to_has_connections(thread_id) + .await + .expect("thread should have a has-connections watcher"); + assert!(*has_connections.borrow()); + + assert!( + manager + .unsubscribe_connection_from_thread(thread_id, connection_a) + .await + ); + tokio::time::timeout(Duration::from_secs(1), has_connections.changed()) + .await + .expect("timed out waiting for no-subscriber update") + .expect("has-connections watcher should remain open"); + assert!(!*has_connections.borrow()); + + assert!( + manager + .try_add_connection_to_thread(thread_id, connection_b) + .await + ); + tokio::time::timeout(Duration::from_secs(1), has_connections.changed()) + .await + .expect("timed out waiting for subscriber update") + .expect("has-connections watcher should remain open"); + assert!(*has_connections.borrow()); + Ok(()) + } + + #[tokio::test] + async fn closed_connection_cannot_be_reintroduced_by_auto_subscribe() -> Result<()> { + let manager = ThreadStateManager::new(); + let thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; + let connection = ConnectionId(1); + + manager + .connection_initialized(connection, ConnectionCapabilities::default()) + .await; + let threads_to_unload = manager.remove_connection(connection).await; + assert_eq!(threads_to_unload, Vec::::new()); + + assert!( + manager + .try_ensure_connection_subscribed( + thread_id, connection, /*experimental_raw_events*/ false + ) + .await + .is_none() + ); + assert!(!manager.has_subscribers(thread_id).await); + Ok(()) + } + + #[tokio::test] + async fn first_attestation_capable_connection_for_thread_only_uses_thread_subscribers() + -> Result<()> { + let manager = ThreadStateManager::new(); + let thread_id = ThreadId::from_string("dfbd9a95-2f44-470a-8bd8-1cfc04efc243")?; + let other_thread_id = ThreadId::from_string("6c9a74e4-5e59-479e-90bf-5c5798bb50aa")?; + let unrelated_supported_connection = ConnectionId(1); + let earlier_supported_connection = ConnectionId(2); + let later_supported_connection = ConnectionId(3); + let unsupported_connection = ConnectionId(4); + + manager + .connection_initialized( + unrelated_supported_connection, + ConnectionCapabilities { + request_attestation: true, + }, + ) + .await; + manager + .connection_initialized( + earlier_supported_connection, + ConnectionCapabilities { + request_attestation: true, + }, + ) + .await; + manager + .connection_initialized( + later_supported_connection, + ConnectionCapabilities { + request_attestation: true, + }, + ) + .await; + manager + .connection_initialized(unsupported_connection, ConnectionCapabilities::default()) + .await; + + assert!( + manager + .try_add_connection_to_thread(other_thread_id, unrelated_supported_connection) + .await + ); + assert!( + manager + .try_add_connection_to_thread(thread_id, later_supported_connection) + .await + ); + assert!( + manager + .try_add_connection_to_thread(thread_id, earlier_supported_connection) + .await + ); + assert!( + manager + .try_add_connection_to_thread(thread_id, unsupported_connection) + .await + ); + + assert_eq!( + manager + .first_attestation_capable_connection_for_thread(thread_id) + .await, + Some(earlier_supported_connection) + ); + assert_eq!( + manager + .first_attestation_capable_connection_for_thread(other_thread_id) + .await, + Some(unrelated_supported_connection) + ); + Ok(()) + } +} diff --git a/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs new file mode 100644 index 0000000000..d547aef3f3 --- /dev/null +++ b/codex-rs/app-server/src/request_processors/thread_resume_redaction.rs @@ -0,0 +1,198 @@ +use codex_app_server_protocol::McpToolCallResult; +use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadItem; +use serde_json::Value as JsonValue; + +// Temporary bandaid for remote clients: thread/resume can include large MCP and +// image-generation payloads. Keep this response-only so persisted rollout +// history, model resume history, and other APIs stay unchanged. +const REDACTED_PAYLOAD: &str = "[redacted]"; +const CHATGPT_REMOTE_CLIENT_NAMES: &[&str] = + &["codex_chatgpt_android_remote", "codex_chatgpt_ios_remote"]; + +pub(super) fn should_redact_thread_resume_payloads(client_name: Option<&str>) -> bool { + client_name.is_some_and(|client_name| CHATGPT_REMOTE_CLIENT_NAMES.contains(&client_name)) +} + +pub(super) fn redact_thread_resume_payloads(thread: &mut Thread) { + for turn in &mut thread.turns { + turn.items.retain_mut(|item| match item { + ThreadItem::McpToolCall { + arguments, + result, + error, + .. + } => { + *arguments = JsonValue::String(REDACTED_PAYLOAD.to_string()); + if result.is_some() { + *result = Some(Box::new(redacted_mcp_tool_call_result())); + } + if let Some(error) = error { + error.message = REDACTED_PAYLOAD.to_string(); + } + true + } + ThreadItem::ImageGeneration { .. } => false, + _ => true, + }); + } +} + +fn redacted_mcp_tool_call_result() -> McpToolCallResult { + McpToolCallResult { + content: vec![serde_json::json!({ + "type": "text", + "text": REDACTED_PAYLOAD, + })], + structured_content: None, + meta: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::McpToolCallError; + use codex_app_server_protocol::McpToolCallStatus; + use codex_app_server_protocol::SessionSource; + use codex_app_server_protocol::ThreadStatus; + use codex_app_server_protocol::Turn; + use codex_app_server_protocol::TurnItemsView; + use codex_app_server_protocol::TurnStatus; + use codex_utils_absolute_path::test_support::PathBufExt; + use codex_utils_absolute_path::test_support::test_path_buf; + use pretty_assertions::assert_eq; + + #[test] + fn redacts_mcp_success_result_and_removes_image_generation() { + let mut thread = test_thread(vec![ + ThreadItem::AgentMessage { + id: "agent-1".to_string(), + text: "kept".to_string(), + phase: None, + memory_citation: None, + }, + ThreadItem::McpToolCall { + id: "mcp-1".to_string(), + server: "docs".to_string(), + tool: "lookup".to_string(), + status: McpToolCallStatus::Completed, + arguments: serde_json::json!({"secret":"argument"}), + mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()), + result: Some(Box::new(McpToolCallResult { + content: vec![serde_json::json!({ + "type": "text", + "text": "secret result" + })], + structured_content: Some(serde_json::json!({"secret":"structured"})), + meta: Some(serde_json::json!({"secret":"meta"})), + })), + error: None, + duration_ms: Some(8), + }, + ThreadItem::ImageGeneration { + id: "ig-1".to_string(), + status: "completed".to_string(), + revised_prompt: Some("revised".to_string()), + result: "base64-result".to_string(), + saved_path: Some(test_path_buf("/tmp/ig-1.png").abs()), + }, + ]); + + redact_thread_resume_payloads(&mut thread); + + assert_eq!(thread.turns[0].items.len(), 2); + assert_eq!( + thread.turns[0].items[0], + ThreadItem::AgentMessage { + id: "agent-1".to_string(), + text: "kept".to_string(), + phase: None, + memory_citation: None, + } + ); + assert_eq!( + thread.turns[0].items[1], + ThreadItem::McpToolCall { + id: "mcp-1".to_string(), + server: "docs".to_string(), + tool: "lookup".to_string(), + status: McpToolCallStatus::Completed, + arguments: JsonValue::String(REDACTED_PAYLOAD.to_string()), + mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()), + result: Some(Box::new(redacted_mcp_tool_call_result())), + error: None, + duration_ms: Some(8), + } + ); + } + + #[test] + fn redacts_mcp_error_message() { + let mut thread = test_thread(vec![ThreadItem::McpToolCall { + id: "mcp-1".to_string(), + server: "docs".to_string(), + tool: "lookup".to_string(), + status: McpToolCallStatus::Failed, + arguments: serde_json::json!({"secret":"argument"}), + mcp_app_resource_uri: None, + result: None, + error: Some(McpToolCallError { + message: "secret error".to_string(), + }), + duration_ms: Some(8), + }]); + + redact_thread_resume_payloads(&mut thread); + + assert_eq!( + thread.turns[0].items[0], + ThreadItem::McpToolCall { + id: "mcp-1".to_string(), + server: "docs".to_string(), + tool: "lookup".to_string(), + status: McpToolCallStatus::Failed, + arguments: JsonValue::String(REDACTED_PAYLOAD.to_string()), + mcp_app_resource_uri: None, + result: None, + error: Some(McpToolCallError { + message: REDACTED_PAYLOAD.to_string(), + }), + duration_ms: Some(8), + } + ); + } + + fn test_thread(items: Vec) -> Thread { + Thread { + id: "thread-1".to_string(), + session_id: "session-1".to_string(), + forked_from_id: None, + preview: "preview".to_string(), + ephemeral: false, + model_provider: "mock_provider".to_string(), + created_at: 0, + updated_at: 0, + status: ThreadStatus::Idle, + path: None, + cwd: test_path_buf("/tmp").abs(), + cli_version: "0.0.0".to_string(), + source: SessionSource::Cli, + thread_source: None, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: vec![Turn { + id: "turn-1".to_string(), + items, + items_view: TurnItemsView::Full, + status: TurnStatus::Completed, + error: None, + started_at: None, + completed_at: None, + duration_ms: None, + }], + } + } +} diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs new file mode 100644 index 0000000000..875bd3deaf --- /dev/null +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -0,0 +1,300 @@ +use super::*; + +#[cfg(test)] +use chrono::DateTime; +#[cfg(test)] +use chrono::Utc; + +#[cfg(test)] +pub(crate) async fn read_summary_from_rollout( + path: &Path, + fallback_provider: &str, +) -> std::io::Result { + let head = read_head_for_summary(path).await?; + + let Some(first) = head.first() else { + return Err(IoError::other(format!( + "rollout at {} is empty", + path.display() + ))); + }; + + let session_meta_line = + serde_json::from_value::(first.clone()).map_err(|_| { + IoError::other(format!( + "rollout at {} does not start with session metadata", + path.display() + )) + })?; + let SessionMetaLine { + meta: session_meta, + git, + } = session_meta_line; + let mut session_meta = session_meta; + session_meta.source = with_thread_spawn_agent_metadata( + session_meta.source.clone(), + session_meta.agent_nickname.clone(), + session_meta.agent_role.clone(), + ); + + let created_at = if session_meta.timestamp.is_empty() { + None + } else { + Some(session_meta.timestamp.as_str()) + }; + let updated_at = read_updated_at(path, created_at).await; + if let Some(summary) = extract_conversation_summary( + path.to_path_buf(), + &head, + &session_meta, + git.as_ref(), + fallback_provider, + updated_at.clone(), + ) { + return Ok(summary); + } + + let timestamp = if session_meta.timestamp.is_empty() { + None + } else { + Some(session_meta.timestamp.clone()) + }; + let model_provider = session_meta + .model_provider + .clone() + .unwrap_or_else(|| fallback_provider.to_string()); + let git_info = git.as_ref().map(map_git_info); + let updated_at = updated_at.or_else(|| timestamp.clone()); + + Ok(ConversationSummary { + conversation_id: session_meta.id, + timestamp, + updated_at, + path: path.to_path_buf(), + preview: String::new(), + model_provider, + cwd: session_meta.cwd, + cli_version: session_meta.cli_version, + source: session_meta.source, + git_info, + }) +} + +#[cfg(test)] +fn extract_conversation_summary( + path: PathBuf, + head: &[serde_json::Value], + session_meta: &SessionMeta, + git: Option<&CoreGitInfo>, + fallback_provider: &str, + updated_at: Option, +) -> Option { + let preview = head + .iter() + .filter_map(|value| serde_json::from_value::(value.clone()).ok()) + .find_map(|item| match codex_core::parse_turn_item(&item) { + Some(TurnItem::UserMessage(user)) => Some(user.message()), + _ => None, + })?; + + let preview = match preview.find(USER_MESSAGE_BEGIN) { + Some(idx) => preview[idx + USER_MESSAGE_BEGIN.len()..].trim(), + None => preview.as_str(), + }; + + let timestamp = if session_meta.timestamp.is_empty() { + None + } else { + Some(session_meta.timestamp.clone()) + }; + let conversation_id = session_meta.id; + let model_provider = session_meta + .model_provider + .clone() + .unwrap_or_else(|| fallback_provider.to_string()); + let git_info = git.map(map_git_info); + let updated_at = updated_at.or_else(|| timestamp.clone()); + + Some(ConversationSummary { + conversation_id, + timestamp, + updated_at, + path, + preview: preview.to_string(), + model_provider, + cwd: session_meta.cwd.clone(), + cli_version: session_meta.cli_version.clone(), + source: session_meta.source.clone(), + git_info, + }) +} + +#[cfg(test)] +fn map_git_info(git_info: &CoreGitInfo) -> ConversationGitInfo { + ConversationGitInfo { + sha: git_info.commit_hash.as_ref().map(|sha| sha.0.clone()), + branch: git_info.branch.clone(), + origin_url: git_info.repository_url.clone(), + } +} + +pub(super) fn with_thread_spawn_agent_metadata( + source: codex_protocol::protocol::SessionSource, + agent_nickname: Option, + agent_role: Option, +) -> codex_protocol::protocol::SessionSource { + if agent_nickname.is_none() && agent_role.is_none() { + return source; + } + + match source { + codex_protocol::protocol::SessionSource::SubAgent( + codex_protocol::protocol::SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_path, + agent_nickname: existing_agent_nickname, + agent_role: existing_agent_role, + }, + ) => codex_protocol::protocol::SessionSource::SubAgent( + codex_protocol::protocol::SubAgentSource::ThreadSpawn { + parent_thread_id, + depth, + agent_path, + agent_nickname: agent_nickname.or(existing_agent_nickname), + agent_role: agent_role.or(existing_agent_role), + }, + ), + _ => source, + } +} + +pub(super) fn thread_response_active_permission_profile( + active_permission_profile: Option, +) -> Option { + active_permission_profile.map(Into::into) +} + +pub(super) fn apply_permission_profile_selection_to_config_overrides( + overrides: &mut ConfigOverrides, + permissions: Option, +) { + let Some(PermissionProfileSelectionParams::Profile { id, modifications }) = permissions else { + return; + }; + overrides.default_permissions = Some(id); + overrides + .additional_writable_roots + .extend(modifications.unwrap_or_default().into_iter().map( + |modification| match modification { + PermissionProfileModificationParams::AdditionalWritableRoot { path } => { + path.to_path_buf() + } + }, + )); +} + +pub(super) fn thread_response_sandbox_policy( + permission_profile: &codex_protocol::models::PermissionProfile, + cwd: &Path, +) -> codex_app_server_protocol::SandboxPolicy { + let file_system_policy = permission_profile.file_system_sandbox_policy(); + let sandbox_policy = codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( + permission_profile, + &file_system_policy, + permission_profile.network_sandbox_policy(), + cwd, + ); + sandbox_policy.into() +} + +#[cfg(test)] +fn parse_datetime(timestamp: Option<&str>) -> Option> { + timestamp.and_then(|ts| { + chrono::DateTime::parse_from_rfc3339(ts) + .ok() + .map(|dt| dt.with_timezone(&chrono::Utc)) + }) +} + +#[cfg(test)] +async fn read_updated_at(path: &Path, created_at: Option<&str>) -> Option { + let updated_at = tokio::fs::metadata(path) + .await + .ok() + .and_then(|meta| meta.modified().ok()) + .map(|modified| { + let updated_at: DateTime = modified.into(); + updated_at.to_rfc3339_opts(SecondsFormat::Millis, true) + }); + updated_at.or_else(|| created_at.map(str::to_string)) +} + +pub(super) fn thread_started_notification(mut thread: Thread) -> ThreadStartedNotification { + thread.turns.clear(); + ThreadStartedNotification { thread } +} + +#[cfg(test)] +pub(crate) fn summary_to_thread( + summary: ConversationSummary, + fallback_cwd: &AbsolutePathBuf, +) -> Thread { + let ConversationSummary { + conversation_id, + path, + preview, + timestamp, + updated_at, + model_provider, + cwd, + cli_version, + source, + git_info, + } = summary; + + let created_at = parse_datetime(timestamp.as_deref()); + let updated_at = parse_datetime(updated_at.as_deref()).or(created_at); + let git_info = git_info.map(|info| ApiGitInfo { + sha: info.sha, + branch: info.branch, + origin_url: info.origin_url, + }); + let cwd = + AbsolutePathBuf::relative_to_current_dir(path_utils::normalize_for_native_workdir(cwd)) + .unwrap_or_else(|err| { + warn!( + conversation_id = %conversation_id, + path = %path.display(), + "failed to normalize thread cwd while summarizing thread: {err}" + ); + fallback_cwd.clone() + }); + + let thread_id = conversation_id.to_string(); + Thread { + id: thread_id.clone(), + session_id: thread_id, + forked_from_id: None, + preview, + ephemeral: false, + model_provider, + created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0), + updated_at: updated_at.map(|dt| dt.timestamp()).unwrap_or(0), + status: ThreadStatus::NotLoaded, + path: (!path.as_os_str().is_empty()).then_some(path), + cwd, + cli_version, + agent_nickname: source.get_nickname(), + agent_role: source.get_agent_role(), + source: source.into(), + thread_source: None, + git_info, + name: None, + turns: Vec::new(), + } +} + +#[cfg(test)] +#[path = "thread_summary_tests.rs"] +mod thread_summary_tests; diff --git a/codex-rs/app-server/src/request_processors/thread_summary_tests.rs b/codex-rs/app-server/src/request_processors/thread_summary_tests.rs new file mode 100644 index 0000000000..f8902e132d --- /dev/null +++ b/codex-rs/app-server/src/request_processors/thread_summary_tests.rs @@ -0,0 +1,68 @@ +use super::*; + +use anyhow::Result; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::path::PathBuf; + +#[test] +fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> { + let conversation_id = ThreadId::from_string("3f941c35-29b3-493b-b0a4-e25800d9aeb0")?; + let timestamp = Some("2025-09-05T16:53:11.850Z".to_string()); + let path = PathBuf::from("rollout.jsonl"); + + let head = vec![ + json!({ + "id": conversation_id.to_string(), + "timestamp": timestamp, + "cwd": "/", + "originator": "codex", + "cli_version": "0.0.0", + "model_provider": "test-provider" + }), + json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": "# AGENTS.md instructions for project\n\n\n\n".to_string(), + }], + }), + json!({ + "type": "message", + "role": "user", + "content": [{ + "type": "input_text", + "text": format!(" {USER_MESSAGE_BEGIN}Count to 5"), + }], + }), + ]; + + let session_meta = serde_json::from_value::(head[0].clone())?; + + let summary = extract_conversation_summary( + path.clone(), + &head, + &session_meta, + /*git*/ None, + "test-provider", + timestamp.clone(), + ) + .expect("summary"); + + let expected = ConversationSummary { + conversation_id, + timestamp: timestamp.clone(), + updated_at: timestamp, + path, + preview: "Count to 5".to_string(), + model_provider: "test-provider".to_string(), + cwd: PathBuf::from("/"), + cli_version: "0.0.0".to_string(), + source: codex_protocol::protocol::SessionSource::VSCode, + git_info: None, + }; + + assert_eq!(summary, expected); + Ok(()) +} diff --git a/codex-rs/app-server/src/codex_message_processor/token_usage_replay.rs b/codex-rs/app-server/src/request_processors/token_usage_replay.rs similarity index 66% rename from codex-rs/app-server/src/codex_message_processor/token_usage_replay.rs rename to codex-rs/app-server/src/request_processors/token_usage_replay.rs index bcd972a470..b19c4a61a0 100644 --- a/codex-rs/app-server/src/codex_message_processor/token_usage_replay.rs +++ b/codex-rs/app-server/src/request_processors/token_usage_replay.rs @@ -9,7 +9,6 @@ //! the time the `TokenCount` was persisted so the notification still targets the //! corresponding rebuilt turn. -use std::path::Path; use std::sync::Arc; use codex_app_server_protocol::ServerNotification; @@ -24,7 +23,6 @@ use codex_protocol::ThreadId; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; -use crate::codex_message_processor::read_rollout_items_from_rollout; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingMessageSender; @@ -32,7 +30,7 @@ use crate::outgoing_message::OutgoingMessageSender; /// /// This is lifecycle replay rather than a model event: the rollout already contains /// the original `TokenCount`, and emitting through `send_event` here would duplicate -/// persisted usage records. Keeping this helper connection-scoped also avoids +/// persisted usage records. Keeping replay connection-scoped also avoids /// surprising other subscribers with a historical usage update while they may be /// rendering live turn events. pub(super) async fn send_thread_token_usage_update_to_connection( @@ -59,19 +57,6 @@ pub(super) async fn send_thread_token_usage_update_to_connection( .await; } -pub(super) async fn latest_token_usage_turn_id_for_thread_path(thread: &Thread) -> Option { - let rollout_path = thread.path.as_deref()?; - latest_token_usage_turn_id_from_rollout_path(rollout_path, thread.turns.as_slice()).await -} - -pub(super) async fn latest_token_usage_turn_id_from_rollout_path( - rollout_path: &Path, - turns: &[Turn], -) -> Option { - let rollout_items = read_rollout_items_from_rollout(rollout_path).await.ok()?; - latest_token_usage_turn_id_from_rollout_items(&rollout_items, turns) -} - /// Identifies the turn that was active when a `TokenCount` record appeared. /// /// The id is preferred when it still appears in the rebuilt thread. The position is a @@ -127,3 +112,62 @@ fn latest_token_usage_turn_id(thread: &Thread) -> String { .map(|turn| turn.id.clone()) .unwrap_or_default() } + +#[cfg(test)] +mod tests { + use super::*; + use codex_app_server_protocol::build_turns_from_rollout_items; + use codex_protocol::protocol::AgentMessageEvent; + use codex_protocol::protocol::TokenCountEvent; + use codex_protocol::protocol::UserMessageEvent; + use pretty_assertions::assert_eq; + + #[test] + fn replay_attribution_uses_already_loaded_history() { + let rollout_items = token_usage_history(); + let turns = build_turns_from_rollout_items(&rollout_items); + + assert_eq!( + latest_token_usage_turn_id_from_rollout_items(&rollout_items, turns.as_slice()), + Some(turns[0].id.clone()) + ); + } + + #[test] + fn replay_attribution_falls_back_to_rebuilt_turn_position() { + let rollout_items = token_usage_history(); + let mut turns = build_turns_from_rollout_items(&rollout_items); + turns[0].id = "rebuilt-turn-id".to_string(); + + assert_eq!( + latest_token_usage_turn_id_from_rollout_items(&rollout_items, turns.as_slice()), + Some("rebuilt-turn-id".to_string()) + ); + } + + fn token_usage_history() -> Vec { + vec![ + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "first turn".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + })), + RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent { + message: "first answer".to_string(), + phase: None, + memory_citation: None, + })), + RolloutItem::EventMsg(EventMsg::TokenCount(TokenCountEvent { + info: None, + rate_limits: None, + })), + RolloutItem::EventMsg(EventMsg::UserMessage(UserMessageEvent { + message: "second turn".to_string(), + images: None, + local_images: Vec::new(), + text_elements: Vec::new(), + })), + ] + } +} diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs new file mode 100644 index 0000000000..d1dae4ef46 --- /dev/null +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -0,0 +1,1122 @@ +use super::*; + +#[derive(Clone)] +pub(crate) struct TurnRequestProcessor { + auth_manager: Arc, + thread_manager: Arc, + outgoing: Arc, + analytics_events_client: AnalyticsEventsClient, + arg0_paths: Arg0DispatchPaths, + config: Arc, + config_manager: ConfigManager, + pending_thread_unloads: Arc>>, + thread_state_manager: ThreadStateManager, + thread_watch_manager: ThreadWatchManager, + thread_list_state_permit: Arc, + skills_watcher: Arc, +} + +impl TurnRequestProcessor { + #[allow(clippy::too_many_arguments)] + pub(crate) fn new( + auth_manager: Arc, + thread_manager: Arc, + outgoing: Arc, + analytics_events_client: AnalyticsEventsClient, + arg0_paths: Arg0DispatchPaths, + config: Arc, + config_manager: ConfigManager, + pending_thread_unloads: Arc>>, + thread_state_manager: ThreadStateManager, + thread_watch_manager: ThreadWatchManager, + thread_list_state_permit: Arc, + skills_watcher: Arc, + ) -> Self { + Self { + auth_manager, + thread_manager, + outgoing, + analytics_events_client, + arg0_paths, + config, + config_manager, + pending_thread_unloads, + thread_state_manager, + thread_watch_manager, + thread_list_state_permit, + skills_watcher, + } + } + + pub(crate) async fn turn_start( + &self, + request_id: ConnectionRequestId, + params: TurnStartParams, + app_server_client_name: Option, + app_server_client_version: Option, + ) -> Result, JSONRPCErrorError> { + self.turn_start_inner( + request_id, + params, + app_server_client_name, + app_server_client_version, + ) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn thread_inject_items( + &self, + params: ThreadInjectItemsParams, + ) -> Result, JSONRPCErrorError> { + self.thread_inject_items_response_inner(params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn turn_steer( + &self, + request_id: &ConnectionRequestId, + params: TurnSteerParams, + ) -> Result, JSONRPCErrorError> { + self.turn_steer_inner(request_id, params) + .await + .map(|response| Some(response.into())) + } + + pub(crate) async fn turn_interrupt( + &self, + request_id: &ConnectionRequestId, + params: TurnInterruptParams, + ) -> Result, JSONRPCErrorError> { + self.turn_interrupt_inner(request_id, params) + .await + .map(|response| response.map(Into::into)) + } + + pub(crate) async fn thread_realtime_start( + &self, + request_id: &ConnectionRequestId, + params: ThreadRealtimeStartParams, + ) -> Result, JSONRPCErrorError> { + self.thread_realtime_start_inner(request_id, params) + .await + .map(|response| response.map(Into::into)) + } + + pub(crate) async fn thread_realtime_append_audio( + &self, + request_id: &ConnectionRequestId, + params: ThreadRealtimeAppendAudioParams, + ) -> Result, JSONRPCErrorError> { + self.thread_realtime_append_audio_inner(request_id, params) + .await + .map(|response| response.map(Into::into)) + } + + pub(crate) async fn thread_realtime_append_text( + &self, + request_id: &ConnectionRequestId, + params: ThreadRealtimeAppendTextParams, + ) -> Result, JSONRPCErrorError> { + self.thread_realtime_append_text_inner(request_id, params) + .await + .map(|response| response.map(Into::into)) + } + + pub(crate) async fn thread_realtime_stop( + &self, + request_id: &ConnectionRequestId, + params: ThreadRealtimeStopParams, + ) -> Result, JSONRPCErrorError> { + self.thread_realtime_stop_inner(request_id, params) + .await + .map(|response| response.map(Into::into)) + } + + pub(crate) async fn thread_realtime_list_voices( + &self, + ) -> Result, JSONRPCErrorError> { + Ok(Some( + ThreadRealtimeListVoicesResponse { + voices: RealtimeVoicesList::builtin(), + } + .into(), + )) + } + + pub(crate) async fn review_start( + &self, + request_id: &ConnectionRequestId, + params: ReviewStartParams, + ) -> Result, JSONRPCErrorError> { + self.review_start_inner(request_id, params) + .await + .map(|()| None) + } + + fn track_error_response( + &self, + request_id: &ConnectionRequestId, + error: &JSONRPCErrorError, + error_type: Option, + ) { + self.analytics_events_client.track_error_response( + request_id.connection_id.0, + request_id.request_id.clone(), + error.clone(), + error_type, + ); + } + + async fn load_thread( + &self, + thread_id: &str, + ) -> Result<(ThreadId, Arc), JSONRPCErrorError> { + // Resolve the core conversation handle from a v2 thread id string. + let thread_id = ThreadId::from_string(thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}")))?; + + let thread = self + .thread_manager + .get_thread(thread_id) + .await + .map_err(|_| invalid_request(format!("thread not found: {thread_id}")))?; + + Ok((thread_id, thread)) + } + fn normalize_turn_start_collaboration_mode( + &self, + mut collaboration_mode: CollaborationMode, + ) -> CollaborationMode { + if collaboration_mode.settings.developer_instructions.is_none() + && let Some(instructions) = builtin_collaboration_mode_presets() + .into_iter() + .find(|preset| preset.mode == Some(collaboration_mode.mode)) + .and_then(|preset| preset.developer_instructions.flatten()) + .filter(|instructions| !instructions.is_empty()) + { + collaboration_mode.settings.developer_instructions = Some(instructions); + } + + collaboration_mode + } + + fn review_request_from_target( + target: ApiReviewTarget, + ) -> Result<(ReviewRequest, String), JSONRPCErrorError> { + let cleaned_target = match target { + ApiReviewTarget::UncommittedChanges => ApiReviewTarget::UncommittedChanges, + ApiReviewTarget::BaseBranch { branch } => { + let branch = branch.trim().to_string(); + if branch.is_empty() { + return Err(invalid_request("branch must not be empty".to_string())); + } + ApiReviewTarget::BaseBranch { branch } + } + ApiReviewTarget::Commit { sha, title } => { + let sha = sha.trim().to_string(); + if sha.is_empty() { + return Err(invalid_request("sha must not be empty".to_string())); + } + let title = title + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()); + ApiReviewTarget::Commit { sha, title } + } + ApiReviewTarget::Custom { instructions } => { + let trimmed = instructions.trim().to_string(); + if trimmed.is_empty() { + return Err(invalid_request( + "instructions must not be empty".to_string(), + )); + } + ApiReviewTarget::Custom { + instructions: trimmed, + } + } + }; + + let core_target = match cleaned_target { + ApiReviewTarget::UncommittedChanges => CoreReviewTarget::UncommittedChanges, + ApiReviewTarget::BaseBranch { branch } => CoreReviewTarget::BaseBranch { branch }, + ApiReviewTarget::Commit { sha, title } => CoreReviewTarget::Commit { sha, title }, + ApiReviewTarget::Custom { instructions } => CoreReviewTarget::Custom { instructions }, + }; + + let hint = codex_core::review_prompts::user_facing_hint(&core_target); + let review_request = ReviewRequest { + target: core_target, + user_facing_hint: Some(hint.clone()), + }; + + Ok((review_request, hint)) + } + + fn parse_environment_selections( + &self, + environments: Option>, + ) -> Result>, JSONRPCErrorError> { + let environment_selections = environments.map(|environments| { + environments + .into_iter() + .map(|environment| TurnEnvironmentSelection { + environment_id: environment.environment_id, + cwd: environment.cwd, + }) + .collect::>() + }); + if let Some(environment_selections) = environment_selections.as_ref() { + self.thread_manager + .validate_environment_selections(environment_selections) + .map_err(|err| invalid_request(environment_selection_error_message(err)))?; + } + Ok(environment_selections) + } + + async fn request_trace_context( + &self, + request_id: &ConnectionRequestId, + ) -> Option { + self.outgoing.request_trace_context(request_id).await + } + + async fn submit_core_op( + &self, + request_id: &ConnectionRequestId, + thread: &CodexThread, + op: Op, + ) -> CodexResult { + thread + .submit_with_trace(op, self.request_trace_context(request_id).await) + .await + } + + fn input_too_large_error(actual_chars: usize) -> JSONRPCErrorError { + let mut error = invalid_params(format!( + "Input exceeds the maximum length of {MAX_USER_INPUT_TEXT_CHARS} characters." + )); + error.data = Some(serde_json::json!({ + "input_error_code": INPUT_TOO_LARGE_ERROR_CODE, + "max_chars": MAX_USER_INPUT_TEXT_CHARS, + "actual_chars": actual_chars, + })); + error + } + + fn validate_v2_input_limit(items: &[V2UserInput]) -> Result<(), JSONRPCErrorError> { + let actual_chars: usize = items.iter().map(V2UserInput::text_char_count).sum(); + if actual_chars > MAX_USER_INPUT_TEXT_CHARS { + return Err(Self::input_too_large_error(actual_chars)); + } + Ok(()) + } + + async fn turn_start_inner( + &self, + request_id: ConnectionRequestId, + params: TurnStartParams, + app_server_client_name: Option, + app_server_client_version: Option, + ) -> Result { + if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + &request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); + return Err(error); + } + let (thread_id, thread) = + self.load_thread(¶ms.thread_id) + .await + .inspect_err(|error| { + self.track_error_response(&request_id, error, /*error_type*/ None); + })?; + Self::set_app_server_client_info( + thread.as_ref(), + app_server_client_name, + app_server_client_version, + ) + .await + .inspect_err(|error| { + self.track_error_response(&request_id, error, /*error_type*/ None); + })?; + + let collaboration_mode = params + .collaboration_mode + .map(|mode| self.normalize_turn_start_collaboration_mode(mode)); + let environment_selections = self.parse_environment_selections(params.environments)?; + + // Map v2 input items to core input items. + let mapped_items: Vec = params + .input + .into_iter() + .map(V2UserInput::into_core) + .collect(); + let turn_has_input = !mapped_items.is_empty(); + + let has_any_overrides = params.cwd.is_some() + || params.approval_policy.is_some() + || params.approvals_reviewer.is_some() + || params.sandbox_policy.is_some() + || params.permissions.is_some() + || params.model.is_some() + || params.service_tier.is_some() + || params.effort.is_some() + || params.summary.is_some() + || collaboration_mode.is_some() + || params.personality.is_some(); + + if params.sandbox_policy.is_some() && params.permissions.is_some() { + return Err(invalid_request( + "`permissions` cannot be combined with `sandboxPolicy`", + )); + } + + let cwd = params.cwd; + let approval_policy = params.approval_policy.map(AskForApproval::to_core); + let approvals_reviewer = params + .approvals_reviewer + .map(codex_app_server_protocol::ApprovalsReviewer::to_core); + let sandbox_policy = params.sandbox_policy.map(|p| p.to_core()); + let (permission_profile, active_permission_profile) = + if let Some(permissions) = params.permissions { + let snapshot = thread.config_snapshot().await; + let mut overrides = ConfigOverrides { + cwd: cwd.clone(), + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), + ..Default::default() + }; + apply_permission_profile_selection_to_config_overrides( + &mut overrides, + Some(permissions), + ); + let config = self + .config_manager + .load_for_cwd( + /*request_overrides*/ None, + overrides, + Some(snapshot.cwd.to_path_buf()), + ) + .await + .map_err(|err| config_load_error(&err))?; + // Startup config is allowed to fall back when requirements + // disallow a configured profile. An explicit turn request + // is different: reject it before accepting user input. + if let Some(warning) = config.startup_warnings.iter().find(|warning| { + warning.contains("Configured value for `permission_profile` is disallowed") + }) { + return Err(invalid_request(format!( + "invalid turn context override: {warning}" + ))); + } + ( + Some(config.permissions.permission_profile()), + config.permissions.active_permission_profile(), + ) + } else { + (None, None) + }; + let model = params.model; + let effort = params.effort.map(Some); + let summary = params.summary; + let service_tier = params.service_tier; + let personality = params.personality; + + // If any overrides are provided, validate them synchronously so the + // request can fail before accepting user input. The actual update is + // still queued together with the input below to preserve submission order. + if has_any_overrides { + thread + .validate_turn_context_overrides(CodexThreadTurnContextOverrides { + cwd: cwd.clone(), + approval_policy, + approvals_reviewer, + sandbox_policy: sandbox_policy.clone(), + permission_profile: permission_profile.clone(), + active_permission_profile: active_permission_profile.clone(), + windows_sandbox_level: None, + model: model.clone(), + effort, + summary, + service_tier: service_tier.clone(), + collaboration_mode: collaboration_mode.clone(), + personality, + }) + .await + .map_err(|err| invalid_request(format!("invalid turn context override: {err}")))?; + } + + // Start the turn by submitting the user input. Return its submission id as turn_id. + let turn_op = if has_any_overrides { + Op::UserInputWithTurnContext { + items: mapped_items, + environments: environment_selections, + final_output_json_schema: params.output_schema, + responsesapi_client_metadata: params.responsesapi_client_metadata, + cwd, + approval_policy, + approvals_reviewer, + sandbox_policy, + permission_profile, + active_permission_profile, + windows_sandbox_level: None, + model, + effort, + summary, + service_tier, + collaboration_mode, + personality, + } + } else { + Op::UserInput { + items: mapped_items, + environments: environment_selections, + final_output_json_schema: params.output_schema, + responsesapi_client_metadata: params.responsesapi_client_metadata, + } + }; + let turn_id = self + .submit_core_op(&request_id, thread.as_ref(), turn_op) + .await + .map_err(|err| { + let error = internal_error(format!("failed to start turn: {err}")); + self.track_error_response(&request_id, &error, /*error_type*/ None); + error + })?; + + if turn_has_input { + let config_snapshot = thread.config_snapshot().await; + codex_memories_write::start_memories_startup_task( + Arc::clone(&self.thread_manager), + Arc::clone(&self.auth_manager), + thread_id, + Arc::clone(&thread), + thread.config().await, + &config_snapshot.session_source, + ); + } + + self.outgoing + .record_request_turn_id(&request_id, &turn_id) + .await; + let turn = Turn { + id: turn_id, + items: vec![], + items_view: TurnItemsView::NotLoaded, + error: None, + status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, + }; + + Ok(TurnStartResponse { turn }) + } + + async fn thread_inject_items_response_inner( + &self, + params: ThreadInjectItemsParams, + ) -> Result { + let (_, thread) = self.load_thread(¶ms.thread_id).await?; + + let items = params + .items + .into_iter() + .enumerate() + .map(|(index, value)| { + serde_json::from_value::(value) + .map_err(|err| format!("items[{index}] is not a valid response item: {err}")) + }) + .collect::, _>>() + .map_err(invalid_request)?; + + thread + .inject_response_items(items) + .await + .map_err(|err| match err { + CodexErr::InvalidRequest(message) => invalid_request(message), + err => internal_error(format!("failed to inject response items: {err}")), + })?; + Ok(ThreadInjectItemsResponse {}) + } + + async fn set_app_server_client_info( + thread: &CodexThread, + app_server_client_name: Option, + app_server_client_version: Option, + ) -> Result<(), JSONRPCErrorError> { + let mcp_elicitations_auto_deny = xcode_26_4_mcp_elicitations_auto_deny( + app_server_client_name.as_deref(), + app_server_client_version.as_deref(), + ); + thread + .set_app_server_client_info( + app_server_client_name, + app_server_client_version, + mcp_elicitations_auto_deny, + ) + .await + .map_err(|err| internal_error(format!("failed to set app server client info: {err}"))) + } + + async fn turn_steer_inner( + &self, + request_id: &ConnectionRequestId, + params: TurnSteerParams, + ) -> Result { + let (_, thread) = self + .load_thread(¶ms.thread_id) + .await + .inspect_err(|error| { + self.track_error_response(request_id, error, /*error_type*/ None); + })?; + + if params.expected_turn_id.is_empty() { + return Err(invalid_request("expectedTurnId must not be empty")); + } + self.outgoing + .record_request_turn_id(request_id, ¶ms.expected_turn_id) + .await; + if let Err(error) = Self::validate_v2_input_limit(¶ms.input) { + self.track_error_response( + request_id, + &error, + Some(AnalyticsJsonRpcError::Input(InputError::TooLarge)), + ); + return Err(error); + } + + let mapped_items: Vec = params + .input + .into_iter() + .map(V2UserInput::into_core) + .collect(); + + let turn_id = thread + .steer_input( + mapped_items, + Some(¶ms.expected_turn_id), + params.responsesapi_client_metadata, + ) + .await + .map_err(|err| { + let (message, data, error_type) = match err { + SteerInputError::NoActiveTurn(_) => ( + "no active turn to steer".to_string(), + None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::NoActiveTurn, + )), + ), + SteerInputError::ExpectedTurnMismatch { expected, actual } => ( + format!("expected active turn id `{expected}` but found `{actual}`"), + None, + Some(AnalyticsJsonRpcError::TurnSteer( + TurnSteerRequestError::ExpectedTurnMismatch, + )), + ), + SteerInputError::ActiveTurnNotSteerable { turn_kind } => { + let (message, turn_steer_error) = match turn_kind { + codex_protocol::protocol::NonSteerableTurnKind::Review => ( + "cannot steer a review turn".to_string(), + TurnSteerRequestError::NonSteerableReview, + ), + codex_protocol::protocol::NonSteerableTurnKind::Compact => ( + "cannot steer a compact turn".to_string(), + TurnSteerRequestError::NonSteerableCompact, + ), + }; + let error = TurnError { + message: message.clone(), + codex_error_info: Some(CodexErrorInfo::ActiveTurnNotSteerable { + turn_kind: turn_kind.into(), + }), + additional_details: None, + }; + let data = match serde_json::to_value(error) { + Ok(data) => Some(data), + Err(error) => { + tracing::error!( + ?error, + "failed to serialize active-turn-not-steerable turn error" + ); + None + } + }; + ( + message, + data, + Some(AnalyticsJsonRpcError::TurnSteer(turn_steer_error)), + ) + } + SteerInputError::EmptyInput => ( + "input must not be empty".to_string(), + None, + Some(AnalyticsJsonRpcError::Input(InputError::Empty)), + ), + }; + let mut error = invalid_request(message); + error.data = data; + self.track_error_response(request_id, &error, error_type); + error + })?; + Ok(TurnSteerResponse { turn_id }) + } + + async fn prepare_realtime_conversation_thread( + &self, + request_id: &ConnectionRequestId, + thread_id: &str, + ) -> Result)>, JSONRPCErrorError> { + let (thread_id, thread) = self.load_thread(thread_id).await?; + + match self + .ensure_conversation_listener( + thread_id, + request_id.connection_id, + /*raw_events_enabled*/ false, + ) + .await + { + Ok(EnsureConversationListenerResult::Attached) => {} + Ok(EnsureConversationListenerResult::ConnectionClosed) => { + return Ok(None); + } + Err(error) => return Err(error), + } + + if !thread.enabled(Feature::RealtimeConversation) { + return Err(invalid_request(format!( + "thread {thread_id} does not support realtime conversation" + ))); + } + + Ok(Some((thread_id, thread))) + } + + async fn thread_realtime_start_inner( + &self, + request_id: &ConnectionRequestId, + params: ThreadRealtimeStartParams, + ) -> Result, JSONRPCErrorError> { + let Some((_, thread)) = self + .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id) + .await? + else { + return Ok(None); + }; + self.submit_core_op( + request_id, + thread.as_ref(), + Op::RealtimeConversationStart(ConversationStartParams { + output_modality: params.output_modality, + prompt: params.prompt, + realtime_session_id: params.realtime_session_id, + transport: params.transport.map(|transport| match transport { + ThreadRealtimeStartTransport::Websocket => { + ConversationStartTransport::Websocket + } + ThreadRealtimeStartTransport::Webrtc { sdp } => { + ConversationStartTransport::Webrtc { sdp } + } + }), + voice: params.voice, + }), + ) + .await + .map_err(|err| internal_error(format!("failed to start realtime conversation: {err}")))?; + Ok(Some(ThreadRealtimeStartResponse::default())) + } + + async fn thread_realtime_append_audio_inner( + &self, + request_id: &ConnectionRequestId, + params: ThreadRealtimeAppendAudioParams, + ) -> Result, JSONRPCErrorError> { + let Some((_, thread)) = self + .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id) + .await? + else { + return Ok(None); + }; + self.submit_core_op( + request_id, + thread.as_ref(), + Op::RealtimeConversationAudio(ConversationAudioParams { + frame: params.audio.into(), + }), + ) + .await + .map_err(|err| { + internal_error(format!( + "failed to append realtime conversation audio: {err}" + )) + })?; + Ok(Some(ThreadRealtimeAppendAudioResponse::default())) + } + + async fn thread_realtime_append_text_inner( + &self, + request_id: &ConnectionRequestId, + params: ThreadRealtimeAppendTextParams, + ) -> Result, JSONRPCErrorError> { + let Some((_, thread)) = self + .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id) + .await? + else { + return Ok(None); + }; + self.submit_core_op( + request_id, + thread.as_ref(), + Op::RealtimeConversationText(ConversationTextParams { text: params.text }), + ) + .await + .map_err(|err| { + internal_error(format!( + "failed to append realtime conversation text: {err}" + )) + })?; + Ok(Some(ThreadRealtimeAppendTextResponse::default())) + } + + async fn thread_realtime_stop_inner( + &self, + request_id: &ConnectionRequestId, + params: ThreadRealtimeStopParams, + ) -> Result, JSONRPCErrorError> { + let Some((_, thread)) = self + .prepare_realtime_conversation_thread(request_id, ¶ms.thread_id) + .await? + else { + return Ok(None); + }; + self.submit_core_op(request_id, thread.as_ref(), Op::RealtimeConversationClose) + .await + .map_err(|err| { + internal_error(format!("failed to stop realtime conversation: {err}")) + })?; + Ok(Some(ThreadRealtimeStopResponse::default())) + } + + fn build_review_turn(turn_id: String, display_text: &str) -> Turn { + let items = if display_text.is_empty() { + Vec::new() + } else { + vec![ThreadItem::UserMessage { + id: turn_id.clone(), + content: vec![V2UserInput::Text { + text: display_text.to_string(), + // Review prompt display text is synthesized; no UI element ranges to preserve. + text_elements: Vec::new(), + }], + }] + }; + + Turn { + id: turn_id, + items, + items_view: TurnItemsView::NotLoaded, + error: None, + status: TurnStatus::InProgress, + started_at: None, + completed_at: None, + duration_ms: None, + } + } + + async fn emit_review_started( + &self, + request_id: &ConnectionRequestId, + turn: Turn, + review_thread_id: String, + ) { + let response = ReviewStartResponse { + turn, + review_thread_id, + }; + self.outgoing + .send_response(request_id.clone(), response) + .await; + } + + async fn start_inline_review( + &self, + request_id: &ConnectionRequestId, + parent_thread: Arc, + review_request: ReviewRequest, + display_text: &str, + parent_thread_id: String, + ) -> std::result::Result<(), JSONRPCErrorError> { + let turn_id = self + .submit_core_op( + request_id, + parent_thread.as_ref(), + Op::Review { review_request }, + ) + .await + .map_err(|err| internal_error(format!("failed to start review: {err}")))?; + let turn = Self::build_review_turn(turn_id, display_text); + self.emit_review_started(request_id, turn, parent_thread_id) + .await; + Ok(()) + } + + async fn start_detached_review( + &self, + request_id: &ConnectionRequestId, + parent_thread_id: ThreadId, + parent_thread: Arc, + review_request: ReviewRequest, + display_text: &str, + ) -> std::result::Result<(), JSONRPCErrorError> { + parent_thread.ensure_rollout_materialized().await; + parent_thread.flush_rollout().await.map_err(|err| { + internal_error(format!( + "failed to flush parent thread {parent_thread_id}: {err}" + )) + })?; + let parent_history = parent_thread + .load_history(/*include_archived*/ true) + .await + .map_err(|err| { + internal_error(format!( + "failed to load parent thread {parent_thread_id}: {err}" + )) + })?; + + let mut config = self.config.as_ref().clone(); + if let Some(review_model) = &config.review_model { + config.model = Some(review_model.clone()); + } + + let NewThread { + thread_id, + thread: review_thread, + .. + } = self + .thread_manager + .fork_thread_from_history( + ForkSnapshot::Interrupted, + config.clone(), + InitialHistory::Resumed(ResumedHistory { + conversation_id: parent_thread_id, + history: parent_history.items, + rollout_path: parent_thread.rollout_path(), + }), + /*thread_source*/ None, + /*persist_extended_history*/ false, + self.request_trace_context(request_id).await, + ) + .await + .map_err(|err| { + internal_error(format!("error creating detached review thread: {err}")) + })?; + + log_listener_attach_result( + self.ensure_conversation_listener( + thread_id, + request_id.connection_id, + /*raw_events_enabled*/ false, + ) + .await, + thread_id, + request_id.connection_id, + "review thread", + ); + + let fallback_provider = self.config.model_provider_id.as_str(); + match review_thread + .read_thread( + /*include_archived*/ true, /*include_history*/ false, + ) + .await + { + Ok(stored_thread) => { + let (mut thread, _) = + thread_from_stored_thread(stored_thread, fallback_provider, &self.config.cwd); + thread.session_id = review_thread.session_configured().session_id.to_string(); + self.thread_watch_manager + .upsert_thread_silently(thread.clone()) + .await; + thread.status = resolve_thread_status( + self.thread_watch_manager + .loaded_status_for_thread(&thread.id) + .await, + /*has_in_progress_turn*/ false, + ); + let notif = thread_started_notification(thread); + self.outgoing + .send_server_notification(ServerNotification::ThreadStarted(notif)) + .await; + } + Err(err) => { + tracing::warn!("failed to load summary for review thread {thread_id}: {err}"); + } + } + + let turn_id = self + .submit_core_op( + request_id, + review_thread.as_ref(), + Op::Review { review_request }, + ) + .await + .map_err(|err| { + internal_error(format!("failed to start detached review turn: {err}")) + })?; + + let turn = Self::build_review_turn(turn_id, display_text); + let review_thread_id = thread_id.to_string(); + self.emit_review_started(request_id, turn, review_thread_id) + .await; + + Ok(()) + } + + async fn review_start_inner( + &self, + request_id: &ConnectionRequestId, + params: ReviewStartParams, + ) -> Result<(), JSONRPCErrorError> { + let ReviewStartParams { + thread_id, + target, + delivery, + } = params; + + let (parent_thread_id, parent_thread) = self.load_thread(&thread_id).await?; + let (review_request, display_text) = Self::review_request_from_target(target)?; + match delivery.unwrap_or(ApiReviewDelivery::Inline).to_core() { + CoreReviewDelivery::Inline => { + self.start_inline_review( + request_id, + parent_thread, + review_request, + &display_text, + thread_id, + ) + .await?; + } + CoreReviewDelivery::Detached => { + self.start_detached_review( + request_id, + parent_thread_id, + parent_thread, + review_request, + &display_text, + ) + .await?; + } + } + Ok(()) + } + + async fn turn_interrupt_inner( + &self, + request_id: &ConnectionRequestId, + params: TurnInterruptParams, + ) -> Result, JSONRPCErrorError> { + let TurnInterruptParams { thread_id, turn_id } = params; + let is_startup_interrupt = turn_id.is_empty(); + + let (thread_uuid, thread) = self.load_thread(&thread_id).await?; + + // Record turn interrupts so we can reply when TurnAborted arrives. Startup + // interrupts do not have a turn and are acknowledged after submission. + if !is_startup_interrupt { + let thread_state = self.thread_state_manager.thread_state(thread_uuid).await; + let is_running = matches!(thread.agent_status().await, AgentStatus::Running); + { + let mut thread_state = thread_state.lock().await; + if let Some(active_turn) = thread_state.active_turn_snapshot() { + if active_turn.id != turn_id { + return Err(invalid_request(format!( + "expected active turn id {turn_id} but found {}", + active_turn.id + ))); + } + } else if thread_state.last_terminal_turn_id.as_deref() == Some(turn_id.as_str()) + || !is_running + { + return Err(invalid_request("no active turn to interrupt")); + } + thread_state.pending_interrupts.push(request_id.clone()); + } + + self.outgoing + .record_request_turn_id(request_id, &turn_id) + .await; + } + + // Submit the interrupt. Turn interrupts respond upon TurnAborted; startup + // interrupts respond here because startup cancellation has no turn event. + match self + .submit_core_op(request_id, thread.as_ref(), Op::Interrupt) + .await + { + Ok(_) if is_startup_interrupt => Ok(Some(TurnInterruptResponse {})), + Ok(_) => Ok(None), + Err(err) => { + if !is_startup_interrupt { + let thread_state = self.thread_state_manager.thread_state(thread_uuid).await; + let mut thread_state = thread_state.lock().await; + thread_state + .pending_interrupts + .retain(|pending_request_id| pending_request_id != request_id); + } + let interrupt_target = if is_startup_interrupt { + "startup" + } else { + "turn" + }; + Err(internal_error(format!( + "failed to interrupt {interrupt_target}: {err}" + ))) + } + } + } + + fn listener_task_context(&self) -> ListenerTaskContext { + ListenerTaskContext { + thread_manager: Arc::clone(&self.thread_manager), + thread_state_manager: self.thread_state_manager.clone(), + outgoing: Arc::clone(&self.outgoing), + pending_thread_unloads: Arc::clone(&self.pending_thread_unloads), + thread_watch_manager: self.thread_watch_manager.clone(), + thread_list_state_permit: self.thread_list_state_permit.clone(), + fallback_model_provider: self.config.model_provider_id.clone(), + codex_home: self.config.codex_home.to_path_buf(), + skills_watcher: Arc::clone(&self.skills_watcher), + } + } + + async fn ensure_conversation_listener( + &self, + conversation_id: ThreadId, + connection_id: ConnectionId, + raw_events_enabled: bool, + ) -> Result { + super::thread_lifecycle::ensure_conversation_listener( + self.listener_task_context(), + conversation_id, + connection_id, + raw_events_enabled, + ) + .await + } +} + +fn xcode_26_4_mcp_elicitations_auto_deny( + client_name: Option<&str>, + client_version: Option<&str>, +) -> bool { + // Xcode 26.4 shipped before app-server MCP elicitation requests were + // client-visible. Keep elicitations auto-denied for that client line. + // TODO: Remove this compatibility hack once Xcode 26.4 ages out. + client_name == Some("Xcode") + && client_version.is_some_and(|version| version.starts_with("26.4")) +} diff --git a/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs b/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs new file mode 100644 index 0000000000..2392cc8078 --- /dev/null +++ b/codex-rs/app-server/src/request_processors/windows_sandbox_processor.rs @@ -0,0 +1,186 @@ +use super::*; + +#[derive(Clone)] +pub(crate) struct WindowsSandboxRequestProcessor { + outgoing: Arc, + config: Arc, + config_manager: ConfigManager, +} + +impl WindowsSandboxRequestProcessor { + pub(crate) fn new( + outgoing: Arc, + config: Arc, + config_manager: ConfigManager, + ) -> Self { + Self { + outgoing, + config, + config_manager, + } + } + + pub(crate) async fn windows_sandbox_readiness( + &self, + ) -> Result { + Ok(determine_windows_sandbox_readiness(&self.config)) + } + + pub(crate) async fn windows_sandbox_setup_start( + &self, + request_id: &ConnectionRequestId, + params: WindowsSandboxSetupStartParams, + ) -> Result, JSONRPCErrorError> { + self.windows_sandbox_setup_start_inner(request_id, params) + .await + .map(|()| None) + } + + async fn windows_sandbox_setup_start_inner( + &self, + request_id: &ConnectionRequestId, + params: WindowsSandboxSetupStartParams, + ) -> Result<(), JSONRPCErrorError> { + self.outgoing + .send_response( + request_id.clone(), + WindowsSandboxSetupStartResponse { started: true }, + ) + .await; + + let mode = match params.mode { + WindowsSandboxSetupMode::Elevated => CoreWindowsSandboxSetupMode::Elevated, + WindowsSandboxSetupMode::Unelevated => CoreWindowsSandboxSetupMode::Unelevated, + }; + let config = Arc::clone(&self.config); + let config_manager = self.config_manager.clone(); + let command_cwd = params + .cwd + .map(PathBuf::from) + .unwrap_or_else(|| config.cwd.to_path_buf()); + let outgoing = Arc::clone(&self.outgoing); + let connection_id = request_id.connection_id; + + tokio::spawn(async move { + let derived_config = config_manager + .load_for_cwd( + /*request_overrides*/ None, + ConfigOverrides { + cwd: Some(command_cwd.clone()), + ..Default::default() + }, + Some(command_cwd.clone()), + ) + .await; + let setup_result = match derived_config { + Ok(config) => { + let setup_request = WindowsSandboxSetupRequest { + mode, + policy: config + .permissions + .legacy_sandbox_policy(config.cwd.as_path()), + policy_cwd: config.cwd.to_path_buf(), + command_cwd, + env_map: std::env::vars().collect(), + codex_home: config.codex_home.to_path_buf(), + active_profile: config.active_profile.clone(), + }; + codex_core::windows_sandbox::run_windows_sandbox_setup(setup_request).await + } + Err(err) => Err(err.into()), + }; + let notification = WindowsSandboxSetupCompletedNotification { + mode: match mode { + CoreWindowsSandboxSetupMode::Elevated => WindowsSandboxSetupMode::Elevated, + CoreWindowsSandboxSetupMode::Unelevated => WindowsSandboxSetupMode::Unelevated, + }, + success: setup_result.is_ok(), + error: setup_result.err().map(|err| err.to_string()), + }; + outgoing + .send_server_notification_to_connections( + &[connection_id], + ServerNotification::WindowsSandboxSetupCompleted(notification), + ) + .await; + }); + Ok(()) + } +} + +fn determine_windows_sandbox_readiness(config: &Config) -> WindowsSandboxReadinessResponse { + if !cfg!(windows) { + return WindowsSandboxReadinessResponse { + status: WindowsSandboxReadiness::NotConfigured, + }; + } + + determine_windows_sandbox_readiness_from_state( + WindowsSandboxLevel::from_config(config), + sandbox_setup_is_complete(config.codex_home.as_path()), + ) +} + +fn determine_windows_sandbox_readiness_from_state( + windows_sandbox_level: WindowsSandboxLevel, + sandbox_setup_is_complete: bool, +) -> WindowsSandboxReadinessResponse { + let status = match windows_sandbox_level { + WindowsSandboxLevel::Disabled => WindowsSandboxReadiness::NotConfigured, + WindowsSandboxLevel::RestrictedToken => WindowsSandboxReadiness::Ready, + WindowsSandboxLevel::Elevated => { + if sandbox_setup_is_complete { + WindowsSandboxReadiness::Ready + } else { + WindowsSandboxReadiness::UpdateRequired + } + } + }; + + WindowsSandboxReadinessResponse { status } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn determine_windows_sandbox_readiness_reports_not_configured_when_disabled() { + let response = determine_windows_sandbox_readiness_from_state( + WindowsSandboxLevel::Disabled, + /*sandbox_setup_is_complete*/ false, + ); + + assert_eq!(response.status, WindowsSandboxReadiness::NotConfigured); + } + + #[test] + fn determine_windows_sandbox_readiness_reports_ready_for_unelevated_mode() { + let response = determine_windows_sandbox_readiness_from_state( + WindowsSandboxLevel::RestrictedToken, + /*sandbox_setup_is_complete*/ false, + ); + + assert_eq!(response.status, WindowsSandboxReadiness::Ready); + } + + #[test] + fn determine_windows_sandbox_readiness_reports_ready_for_complete_elevated_mode() { + let response = determine_windows_sandbox_readiness_from_state( + WindowsSandboxLevel::Elevated, + /*sandbox_setup_is_complete*/ true, + ); + + assert_eq!(response.status, WindowsSandboxReadiness::Ready); + } + + #[test] + fn determine_windows_sandbox_readiness_reports_update_required_when_elevated_setup_is_stale() { + let response = determine_windows_sandbox_readiness_from_state( + WindowsSandboxLevel::Elevated, + /*sandbox_setup_is_complete*/ false, + ); + + assert_eq!(response.status, WindowsSandboxReadiness::UpdateRequired); + } +} diff --git a/codex-rs/app-server/src/request_serialization.rs b/codex-rs/app-server/src/request_serialization.rs index c3e21d134e..0dd167b74d 100644 --- a/codex-rs/app-server/src/request_serialization.rs +++ b/codex-rs/app-server/src/request_serialization.rs @@ -6,6 +6,7 @@ use std::pin::Pin; use std::sync::Arc; use codex_app_server_protocol::ClientRequestSerializationScope; +use futures::future::join_all; use tokio::sync::Mutex; use tracing::Instrument; @@ -27,6 +28,10 @@ pub(crate) enum RequestSerializationQueueKey { connection_id: ConnectionId, process_id: String, }, + Process { + connection_id: ConnectionId, + process_handle: String, + }, FuzzyFileSearchSession { session_id: String, }, @@ -39,31 +44,61 @@ pub(crate) enum RequestSerializationQueueKey { }, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum RequestSerializationAccess { + Exclusive, + SharedRead, +} + impl RequestSerializationQueueKey { pub(crate) fn from_scope( connection_id: ConnectionId, scope: ClientRequestSerializationScope, - ) -> Self { + ) -> (Self, RequestSerializationAccess) { match scope { - ClientRequestSerializationScope::Global(name) => Self::Global(name), - ClientRequestSerializationScope::Thread { thread_id } => Self::Thread { thread_id }, - ClientRequestSerializationScope::ThreadPath { path } => Self::ThreadPath { path }, - ClientRequestSerializationScope::CommandExecProcess { process_id } => { + ClientRequestSerializationScope::Global(name) => { + (Self::Global(name), RequestSerializationAccess::Exclusive) + } + ClientRequestSerializationScope::GlobalSharedRead(name) => { + (Self::Global(name), RequestSerializationAccess::SharedRead) + } + ClientRequestSerializationScope::Thread { thread_id } => ( + Self::Thread { thread_id }, + RequestSerializationAccess::Exclusive, + ), + ClientRequestSerializationScope::ThreadPath { path } => ( + Self::ThreadPath { path }, + RequestSerializationAccess::Exclusive, + ), + ClientRequestSerializationScope::CommandExecProcess { process_id } => ( Self::CommandExecProcess { connection_id, process_id, - } - } - ClientRequestSerializationScope::FuzzyFileSearchSession { session_id } => { - Self::FuzzyFileSearchSession { session_id } - } - ClientRequestSerializationScope::FsWatch { watch_id } => Self::FsWatch { - connection_id, - watch_id, - }, - ClientRequestSerializationScope::McpOauth { server_name } => { - Self::McpOauth { server_name } - } + }, + RequestSerializationAccess::Exclusive, + ), + ClientRequestSerializationScope::Process { process_handle } => ( + Self::Process { + connection_id, + process_handle, + }, + RequestSerializationAccess::Exclusive, + ), + ClientRequestSerializationScope::FuzzyFileSearchSession { session_id } => ( + Self::FuzzyFileSearchSession { session_id }, + RequestSerializationAccess::Exclusive, + ), + ClientRequestSerializationScope::FsWatch { watch_id } => ( + Self::FsWatch { + connection_id, + watch_id, + }, + RequestSerializationAccess::Exclusive, + ), + ClientRequestSerializationScope::McpOauth { server_name } => ( + Self::McpOauth { server_name }, + RequestSerializationAccess::Exclusive, + ), } } } @@ -90,17 +125,24 @@ impl QueuedInitializedRequest { } } +struct QueuedSerializedRequest { + access: RequestSerializationAccess, + request: QueuedInitializedRequest, +} + #[derive(Clone, Default)] pub(crate) struct RequestSerializationQueues { - inner: Arc>>>, + inner: Arc>>>, } impl RequestSerializationQueues { pub(crate) async fn enqueue( &self, key: RequestSerializationQueueKey, + access: RequestSerializationAccess, request: QueuedInitializedRequest, ) { + let request = QueuedSerializedRequest { access, request }; let should_spawn = { let mut queues = self.inner.lock().await; match queues.get_mut(&key) { @@ -126,13 +168,27 @@ impl RequestSerializationQueues { async fn drain(self, key: RequestSerializationQueueKey) { loop { - let request = { + let requests = { let mut queues = self.inner.lock().await; let Some(queue) = queues.get_mut(&key) else { return; }; match queue.pop_front() { - Some(request) => request, + Some(request) => { + let access = request.access; + let mut requests = vec![request]; + if access == RequestSerializationAccess::SharedRead { + while queue.front().is_some_and(|request| { + request.access == RequestSerializationAccess::SharedRead + }) { + let Some(request) = queue.pop_front() else { + break; + }; + requests.push(request); + } + } + requests + } None => { queues.remove(&key); return; @@ -140,7 +196,7 @@ impl RequestSerializationQueues { } }; - request.run().await; + join_all(requests.into_iter().map(|request| request.request.run())).await; } } } @@ -150,6 +206,7 @@ mod tests { use super::*; use pretty_assertions::assert_eq; use std::sync::Arc; + use tokio::sync::broadcast; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::time::Duration; @@ -187,6 +244,7 @@ mod tests { queues .enqueue( key.clone(), + RequestSerializationAccess::Exclusive, QueuedInitializedRequest::new(Arc::clone(&gate), async move { tx.send(value).expect("receiver should be open"); }), @@ -222,6 +280,7 @@ mod tests { queues .enqueue( RequestSerializationQueueKey::Global("blocked"), + RequestSerializationAccess::Exclusive, QueuedInitializedRequest::new(gate(), async move { let _ = blocked_rx.await; }), @@ -230,6 +289,7 @@ mod tests { queues .enqueue( RequestSerializationQueueKey::Global("other"), + RequestSerializationAccess::Exclusive, QueuedInitializedRequest::new(gate(), async move { ran_tx.send(()).expect("receiver should be open"); }), @@ -260,6 +320,7 @@ mod tests { queues .enqueue( key.clone(), + RequestSerializationAccess::Exclusive, QueuedInitializedRequest::new(Arc::clone(&live_gate), async move { tx.send(FIRST_REQUEST_VALUE) .expect("receiver should be open"); @@ -273,6 +334,7 @@ mod tests { queues .enqueue( key.clone(), + RequestSerializationAccess::Exclusive, QueuedInitializedRequest::new(closed_gate, async move { tx.send(SECOND_REQUEST_VALUE) .expect("receiver should be open"); @@ -285,6 +347,7 @@ mod tests { queues .enqueue( key, + RequestSerializationAccess::Exclusive, QueuedInitializedRequest::new(live_gate, async move { tx.send(THIRD_REQUEST_VALUE) .expect("receiver should be open"); @@ -328,6 +391,7 @@ mod tests { queues .enqueue( key.clone(), + RequestSerializationAccess::Exclusive, QueuedInitializedRequest::new(Arc::clone(&live_gate), async move { tx.send(FIRST_REQUEST_VALUE) .expect("receiver should be open"); @@ -341,6 +405,7 @@ mod tests { queues .enqueue( key, + RequestSerializationAccess::Exclusive, QueuedInitializedRequest::new(live_gate.clone(), async move { tx.send(SECOND_REQUEST_VALUE) .expect("receiver should be open"); @@ -377,4 +442,241 @@ mod tests { None ); } + + #[tokio::test] + async fn same_key_shared_reads_run_concurrently() { + let queues = RequestSerializationQueues::default(); + let key = RequestSerializationQueueKey::Global("test"); + let (blocker_started_tx, blocker_started_rx) = oneshot::channel::<()>(); + let (blocker_release_tx, blocker_release_rx) = oneshot::channel::<()>(); + let (started_tx, mut started_rx) = mpsc::unbounded_channel(); + let (release_tx, _) = broadcast::channel::<()>(/*capacity*/ 1); + + queues + .enqueue( + key.clone(), + RequestSerializationAccess::Exclusive, + QueuedInitializedRequest::new(gate(), async move { + blocker_started_tx + .send(()) + .expect("receiver should be open"); + let _ = blocker_release_rx.await; + }), + ) + .await; + timeout(queue_drain_timeout(), blocker_started_rx) + .await + .expect("blocker should start") + .expect("sender should be open"); + + for value in [FIRST_REQUEST_VALUE, SECOND_REQUEST_VALUE] { + let started_tx = started_tx.clone(); + let mut release_rx = release_tx.subscribe(); + queues + .enqueue( + key.clone(), + RequestSerializationAccess::SharedRead, + QueuedInitializedRequest::new(gate(), async move { + started_tx.send(value).expect("receiver should be open"); + let _ = release_rx.recv().await; + }), + ) + .await; + } + drop(started_tx); + blocker_release_tx + .send(()) + .expect("blocker should still be waiting"); + + let mut started = Vec::new(); + for _ in 0..2 { + started.push( + timeout(queue_drain_timeout(), started_rx.recv()) + .await + .expect("timed out waiting for shared read") + .expect("sender should be open"), + ); + } + assert_eq!(started, vec![FIRST_REQUEST_VALUE, SECOND_REQUEST_VALUE]); + + release_tx + .send(()) + .expect("shared reads should still be waiting"); + } + + #[tokio::test] + async fn exclusive_write_waits_for_running_shared_reads() { + let queues = RequestSerializationQueues::default(); + let key = RequestSerializationQueueKey::Global("test"); + let (blocker_started_tx, blocker_started_rx) = oneshot::channel::<()>(); + let (blocker_release_tx, blocker_release_rx) = oneshot::channel::<()>(); + let (read_started_tx, mut read_started_rx) = mpsc::unbounded_channel(); + let (read_release_tx, _) = broadcast::channel::<()>(/*capacity*/ 1); + let (write_started_tx, write_started_rx) = oneshot::channel::<()>(); + + queues + .enqueue( + key.clone(), + RequestSerializationAccess::Exclusive, + QueuedInitializedRequest::new(gate(), async move { + blocker_started_tx + .send(()) + .expect("receiver should be open"); + let _ = blocker_release_rx.await; + }), + ) + .await; + timeout(queue_drain_timeout(), blocker_started_rx) + .await + .expect("blocker should start") + .expect("sender should be open"); + + for value in [FIRST_REQUEST_VALUE, SECOND_REQUEST_VALUE] { + let read_started_tx = read_started_tx.clone(); + let mut read_release_rx = read_release_tx.subscribe(); + queues + .enqueue( + key.clone(), + RequestSerializationAccess::SharedRead, + QueuedInitializedRequest::new(gate(), async move { + read_started_tx + .send(value) + .expect("receiver should be open"); + let _ = read_release_rx.recv().await; + }), + ) + .await; + } + queues + .enqueue( + key.clone(), + RequestSerializationAccess::Exclusive, + QueuedInitializedRequest::new(gate(), async move { + write_started_tx.send(()).expect("receiver should be open"); + }), + ) + .await; + drop(read_started_tx); + blocker_release_tx + .send(()) + .expect("blocker should still be waiting"); + + for _ in 0..2 { + timeout(queue_drain_timeout(), read_started_rx.recv()) + .await + .expect("timed out waiting for shared read") + .expect("sender should be open"); + } + let mut write_started_rx = Box::pin(write_started_rx); + timeout(shutdown_wait_timeout(), &mut write_started_rx) + .await + .expect_err("write should wait for running shared reads"); + + read_release_tx + .send(()) + .expect("shared reads should still be waiting"); + timeout(queue_drain_timeout(), &mut write_started_rx) + .await + .expect("write should start after shared reads finish") + .expect("sender should be open"); + } + + #[tokio::test] + async fn later_shared_reads_do_not_jump_ahead_of_queued_write() { + let queues = RequestSerializationQueues::default(); + let key = RequestSerializationQueueKey::Global("test"); + let (blocker_started_tx, blocker_started_rx) = oneshot::channel::<()>(); + let (blocker_release_tx, blocker_release_rx) = oneshot::channel::<()>(); + let (first_read_started_tx, first_read_started_rx) = oneshot::channel::<()>(); + let (first_read_release_tx, first_read_release_rx) = oneshot::channel::<()>(); + let (write_started_tx, write_started_rx) = oneshot::channel::<()>(); + let (write_release_tx, write_release_rx) = oneshot::channel::<()>(); + let (later_read_started_tx, later_read_started_rx) = oneshot::channel::<()>(); + + queues + .enqueue( + key.clone(), + RequestSerializationAccess::Exclusive, + QueuedInitializedRequest::new(gate(), async move { + blocker_started_tx + .send(()) + .expect("receiver should be open"); + let _ = blocker_release_rx.await; + }), + ) + .await; + timeout(queue_drain_timeout(), blocker_started_rx) + .await + .expect("blocker should start") + .expect("sender should be open"); + + queues + .enqueue( + key.clone(), + RequestSerializationAccess::SharedRead, + QueuedInitializedRequest::new(gate(), async move { + first_read_started_tx + .send(()) + .expect("receiver should be open"); + let _ = first_read_release_rx.await; + }), + ) + .await; + queues + .enqueue( + key.clone(), + RequestSerializationAccess::Exclusive, + QueuedInitializedRequest::new(gate(), async move { + write_started_tx.send(()).expect("receiver should be open"); + let _ = write_release_rx.await; + }), + ) + .await; + queues + .enqueue( + key.clone(), + RequestSerializationAccess::SharedRead, + QueuedInitializedRequest::new(gate(), async move { + later_read_started_tx + .send(()) + .expect("receiver should be open"); + }), + ) + .await; + blocker_release_tx + .send(()) + .expect("blocker should still be waiting"); + + timeout(queue_drain_timeout(), first_read_started_rx) + .await + .expect("first read should start") + .expect("sender should be open"); + let mut write_started_rx = Box::pin(write_started_rx); + timeout(shutdown_wait_timeout(), &mut write_started_rx) + .await + .expect_err("write should wait for the first read"); + let mut later_read_started_rx = Box::pin(later_read_started_rx); + timeout(shutdown_wait_timeout(), &mut later_read_started_rx) + .await + .expect_err("later read should wait behind the queued write"); + + first_read_release_tx + .send(()) + .expect("first read should still be waiting"); + timeout(queue_drain_timeout(), &mut write_started_rx) + .await + .expect("write should start after the first read") + .expect("sender should be open"); + timeout(shutdown_wait_timeout(), &mut later_read_started_rx) + .await + .expect_err("later read should still wait while the write is running"); + + write_release_tx + .send(()) + .expect("write should still be waiting"); + timeout(queue_drain_timeout(), &mut later_read_started_rx) + .await + .expect("later read should start after the write") + .expect("sender should be open"); + } } diff --git a/codex-rs/app-server/src/skills_watcher.rs b/codex-rs/app-server/src/skills_watcher.rs new file mode 100644 index 0000000000..d20628d386 --- /dev/null +++ b/codex-rs/app-server/src/skills_watcher.rs @@ -0,0 +1,112 @@ +use std::sync::Arc; +use std::time::Duration; + +use crate::outgoing_message::OutgoingMessageSender; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::SkillsChangedNotification; +use codex_core::ThreadManager; +use codex_core::config::Config; +use codex_core::skills::SkillsLoadInput; +use codex_core::skills::SkillsManager; +use codex_file_watcher::FileWatcher; +use codex_file_watcher::FileWatcherSubscriber; +use codex_file_watcher::Receiver; +use codex_file_watcher::ThrottledWatchReceiver; +use codex_file_watcher::WatchPath; +use codex_file_watcher::WatchRegistration; +use codex_protocol::protocol::TurnEnvironmentSelection; +use tracing::warn; + +#[cfg(not(test))] +const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_secs(10); +#[cfg(test)] +const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_millis(50); + +pub(crate) struct SkillsWatcher { + subscriber: FileWatcherSubscriber, +} + +impl SkillsWatcher { + pub(crate) fn new( + skills_manager: Arc, + outgoing: Arc, + ) -> Arc { + let file_watcher = match FileWatcher::new() { + Ok(file_watcher) => Arc::new(file_watcher), + Err(err) => { + warn!("failed to initialize skills file watcher: {err}"); + Arc::new(FileWatcher::noop()) + } + }; + let (subscriber, rx) = file_watcher.add_subscriber(); + Self::spawn_event_loop(rx, skills_manager, outgoing); + Arc::new(Self { subscriber }) + } + + pub(crate) async fn register_thread_config( + &self, + config: &Config, + thread_manager: &ThreadManager, + environments: &[TurnEnvironmentSelection], + ) -> WatchRegistration { + let Some(environment_selection) = environments.first() else { + return WatchRegistration::default(); + }; + let Some(environment) = thread_manager + .environment_manager() + .get_environment(&environment_selection.environment_id) + else { + warn!( + "failed to register skills watcher for unknown environment `{}`", + environment_selection.environment_id + ); + return WatchRegistration::default(); + }; + if environment.is_remote() { + return WatchRegistration::default(); + } + + let plugins_input = config.plugins_config_input(); + let plugins_manager = thread_manager.plugins_manager(); + let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await; + let skills_input = SkillsLoadInput::new( + config.cwd.clone(), + plugin_outcome.effective_plugin_skill_roots(), + config.config_layer_stack.clone(), + config.bundled_skills_enabled(), + ); + let roots = thread_manager + .skills_manager() + .skill_roots_for_config(&skills_input, Some(environment.get_filesystem())) + .await + .into_iter() + .map(|root| WatchPath { + path: root.path.into_path_buf(), + recursive: true, + }) + .collect(); + self.subscriber.register_paths(roots) + } + + fn spawn_event_loop( + rx: Receiver, + skills_manager: Arc, + outgoing: Arc, + ) { + let mut rx = ThrottledWatchReceiver::new(rx, WATCHER_THROTTLE_INTERVAL); + let Ok(handle) = tokio::runtime::Handle::try_current() else { + warn!("skills watcher listener skipped: no Tokio runtime available"); + return; + }; + handle.spawn(async move { + while rx.recv().await.is_some() { + skills_manager.clear_cache(); + outgoing + .send_server_notification(ServerNotification::SkillsChanged( + SkillsChangedNotification {}, + )) + .await; + } + }); + } +} diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index dddbcf483b..32dfcc325d 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -7,6 +7,7 @@ use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError; use codex_core::CodexThread; use codex_core::ThreadConfigSnapshot; +use codex_file_watcher::WatchRegistration; use codex_protocol::ThreadId; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; @@ -33,6 +34,7 @@ pub(crate) struct PendingThreadResumeRequest { pub(crate) emit_thread_goal_update: bool, pub(crate) thread_goal_state_db: Option, pub(crate) include_turns: bool, + pub(crate) redact_resume_payloads: bool, } // ThreadListenerCommand is used to perform operations in the context of the thread listener, for serialization purposes. @@ -77,6 +79,7 @@ pub(crate) struct ThreadState { listener_command_tx: Option>, current_turn_history: ThreadHistoryBuilder, listener_thread: Option>, + watch_registration: WatchRegistration, } impl ThreadState { @@ -91,6 +94,7 @@ impl ThreadState { &mut self, cancel_tx: oneshot::Sender<()>, conversation: &Arc, + watch_registration: WatchRegistration, ) -> (mpsc::UnboundedReceiver, u64) { if let Some(previous) = self.cancel_tx.replace(cancel_tx) { let _ = previous.send(()); @@ -99,6 +103,7 @@ impl ThreadState { let (listener_command_tx, listener_command_rx) = mpsc::unbounded_channel(); self.listener_command_tx = Some(listener_command_tx); self.listener_thread = Some(Arc::downgrade(conversation)); + self.watch_registration = watch_registration; (listener_command_rx, self.listener_generation) } @@ -109,6 +114,7 @@ impl ThreadState { self.listener_command_tx = None; self.current_turn_history.reset(); self.listener_thread = None; + self.watch_registration = WatchRegistration::default(); } pub(crate) fn set_experimental_raw_events(&mut self, enabled: bool) { @@ -199,11 +205,16 @@ impl ThreadEntry { #[derive(Default)] struct ThreadStateManagerInner { - live_connections: HashSet, + live_connections: HashMap, threads: HashMap, thread_ids_by_connection: HashMap>, } +#[derive(Clone, Copy, Default)] +pub(crate) struct ConnectionCapabilities { + pub(crate) request_attestation: bool, +} + #[derive(Clone, Default)] pub(crate) struct ThreadStateManager { state: Arc>, @@ -214,12 +225,36 @@ impl ThreadStateManager { Self::default() } - pub(crate) async fn connection_initialized(&self, connection_id: ConnectionId) { + pub(crate) async fn connection_initialized( + &self, + connection_id: ConnectionId, + capabilities: ConnectionCapabilities, + ) { self.state .lock() .await .live_connections - .insert(connection_id); + .insert(connection_id, capabilities); + } + + pub(crate) async fn first_attestation_capable_connection_for_thread( + &self, + thread_id: ThreadId, + ) -> Option { + let state = self.state.lock().await; + state + .threads + .get(&thread_id)? + .connection_ids + .iter() + .filter_map(|connection_id| { + state + .live_connections + .get(connection_id)? + .request_attestation + .then_some(*connection_id) + }) + .min_by_key(|connection_id| connection_id.0) } pub(crate) async fn subscribed_connection_ids(&self, thread_id: ThreadId) -> Vec { @@ -338,7 +373,7 @@ impl ThreadStateManager { ) -> Option>> { let thread_state = { let mut state = self.state.lock().await; - if !state.live_connections.contains(&connection_id) { + if !state.live_connections.contains_key(&connection_id) { return None; } state @@ -366,7 +401,7 @@ impl ThreadStateManager { connection_id: ConnectionId, ) -> bool { let mut state = self.state.lock().await; - if !state.live_connections.contains(&connection_id) { + if !state.live_connections.contains_key(&connection_id) { return false; } state diff --git a/codex-rs/app-server/src/thread_status.rs b/codex-rs/app-server/src/thread_status.rs index b1373c293d..7315a13c02 100644 --- a/codex-rs/app-server/src/thread_status.rs +++ b/codex-rs/app-server/src/thread_status.rs @@ -889,6 +889,7 @@ mod tests { fn test_thread(thread_id: &str, source: codex_app_server_protocol::SessionSource) -> Thread { Thread { id: thread_id.to_string(), + session_id: thread_id.to_string(), forked_from_id: None, preview: String::new(), ephemeral: false, @@ -902,6 +903,7 @@ mod tests { agent_nickname: None, agent_role: None, source, + thread_source: None, git_info: None, name: None, turns: Vec::new(), diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport.rs index 9c16f8a394..3d18cf321b 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport.rs @@ -18,7 +18,7 @@ pub(crate) use codex_app_server_transport::ConnectionId; pub(crate) use codex_app_server_transport::ConnectionOrigin; pub(crate) use codex_app_server_transport::OutgoingMessage; pub(crate) use codex_app_server_transport::QueuedOutgoingMessage; -pub(crate) use codex_app_server_transport::RemoteControlHandle; +pub(crate) use codex_app_server_transport::RemoteControlStartConfig; pub(crate) use codex_app_server_transport::TransportEvent; pub use codex_app_server_transport::app_server_control_socket_path; pub use codex_app_server_transport::auth; @@ -36,7 +36,7 @@ pub(crate) struct ConnectionState { impl ConnectionState { pub(crate) fn new( - origin: ConnectionOrigin, + _origin: ConnectionOrigin, outbound_initialized: Arc, outbound_experimental_api_enabled: Arc, outbound_opted_out_notification_methods: Arc>>, @@ -45,7 +45,7 @@ impl ConnectionState { outbound_initialized, outbound_experimental_api_enabled, outbound_opted_out_notification_methods, - session: Arc::new(ConnectionSessionState::new(origin)), + session: Arc::new(ConnectionSessionState::new()), } } } diff --git a/codex-rs/app-server/src/transport_tests.rs b/codex-rs/app-server/src/transport_tests.rs index 1600b8be87..5790e46a17 100644 --- a/codex-rs/app-server/src/transport_tests.rs +++ b/codex-rs/app-server/src/transport_tests.rs @@ -258,6 +258,7 @@ async fn command_execution_request_approval_strips_additional_permissions_withou thread_id: "thr_123".to_string(), turn_id: "turn_123".to_string(), item_id: "call_123".to_string(), + started_at_ms: 0, approval_id: None, reason: Some("Need extra read access".to_string()), network_approval_context: None, @@ -322,6 +323,7 @@ async fn command_execution_request_approval_keeps_additional_permissions_with_ca thread_id: "thr_123".to_string(), turn_id: "turn_123".to_string(), item_id: "call_123".to_string(), + started_at_ms: 0, approval_id: None, reason: Some("Need extra read access".to_string()), network_approval_context: None, diff --git a/codex-rs/app-server/tests/common/Cargo.toml b/codex-rs/app-server/tests/common/Cargo.toml index aef2f58dff..5b245f40d2 100644 --- a/codex-rs/app-server/tests/common/Cargo.toml +++ b/codex-rs/app-server/tests/common/Cargo.toml @@ -6,6 +6,8 @@ license.workspace = true [lib] path = "lib.rs" +test = false +doctest = false [lints] workspace = true diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 2abdbd8f7c..81a5b2b401 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -61,6 +61,10 @@ use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginSkillReadParams; use codex_app_server_protocol::PluginUninstallParams; +use codex_app_server_protocol::ProcessKillParams; +use codex_app_server_protocol::ProcessResizePtyParams; +use codex_app_server_protocol::ProcessSpawnParams; +use codex_app_server_protocol::ProcessWriteStdinParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::SendAddCreditsNudgeEmailParams; @@ -85,6 +89,7 @@ use codex_app_server_protocol::ThreadRollbackParams; use codex_app_server_protocol::ThreadSetNameParams; use codex_app_server_protocol::ThreadShellCommandParams; use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadTurnsItemsListParams; use codex_app_server_protocol::ThreadTurnsListParams; use codex_app_server_protocol::ThreadUnarchiveParams; use codex_app_server_protocol::ThreadUnsubscribeParams; @@ -518,6 +523,15 @@ impl McpProcess { self.send_request("thread/turns/list", params).await } + /// Send a `thread/turns/items/list` JSON-RPC request. + pub async fn send_thread_turns_items_list_request( + &mut self, + params: ThreadTurnsItemsListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/turns/items/list", params).await + } + /// Send a `model/list` JSON-RPC request. pub async fn send_list_models_request( &mut self, @@ -741,6 +755,42 @@ impl McpProcess { self.send_request("command/exec", params).await } + /// Send a `process/spawn` JSON-RPC request (v2). + pub async fn send_process_spawn_request( + &mut self, + params: ProcessSpawnParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("process/spawn", params).await + } + + /// Send a `process/writeStdin` JSON-RPC request (v2). + pub async fn send_process_write_stdin_request( + &mut self, + params: ProcessWriteStdinParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("process/writeStdin", params).await + } + + /// Send a `process/resizePty` JSON-RPC request (v2). + pub async fn send_process_resize_pty_request( + &mut self, + params: ProcessResizePtyParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("process/resizePty", params).await + } + + /// Send a `process/kill` JSON-RPC request (v2). + pub async fn send_process_kill_request( + &mut self, + params: ProcessKillParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("process/kill", params).await + } + /// Send a `command/exec/write` JSON-RPC request (v2). pub async fn send_command_exec_write_request( &mut self, diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 3b4a58a7ab..be7d5d047f 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -29,6 +29,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { supported_in_api: preset.supported_in_api, priority, additional_speed_tiers: preset.additional_speed_tiers.clone(), + service_tiers: preset.service_tiers.clone(), upgrade: preset.upgrade.as_ref().map(Into::into), base_instructions: "base instructions".to_string(), model_messages: None, diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 06b273754c..6b2a9a0abe 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -138,6 +138,7 @@ pub fn create_fake_rollout_with_source( originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -221,6 +222,7 @@ pub fn create_fake_rollout_with_text_elements( originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index bb938d9ae7..754d1f9467 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -3,20 +3,40 @@ use app_test_support::McpProcess; use app_test_support::create_fake_rollout; use app_test_support::rollout_path; use app_test_support::to_response; +use codex_app_server::in_process; +use codex_app_server::in_process::InProcessStartArgs; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ConversationSummary; use codex_app_server_protocol::GetConversationSummaryParams; use codex_app_server_protocol::GetConversationSummaryResponse; -use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_arg0::Arg0DispatchPaths; +use codex_config::CloudRequirementsLoader; +use codex_config::LoaderOverrides; +use codex_core::config::ConfigBuilder; +use codex_exec_server::EnvironmentManager; +use codex_feedback::CodexFeedback; use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructions; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadMemoryMode; +use codex_thread_store::CreateThreadParams; +use codex_thread_store::InMemoryThreadStore; +use codex_thread_store::ThreadEventPersistenceMode; +use codex_thread_store::ThreadPersistenceMetadata; +use codex_thread_store::ThreadStore; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::path::Path; use std::path::PathBuf; +use std::sync::Arc; use tempfile::TempDir; use tokio::time::timeout; +use uuid::Uuid; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const FILENAME_TS: &str = "2025-01-02T12-00-00"; @@ -25,7 +45,6 @@ const CREATED_AT_RFC3339: &str = "2025-01-02T12:00:00.000Z"; const UPDATED_AT_RFC3339: &str = "2025-01-02T12:00:00.000Z"; const PREVIEW: &str = "Summarize this conversation"; const MODEL_PROVIDER: &str = "openai"; -const INVALID_REQUEST_ERROR_CODE: i64 = -32600; fn expected_summary(conversation_id: ThreadId, path: PathBuf) -> ConversationSummary { ConversationSummary { @@ -47,7 +66,9 @@ fn normalized_canonical_path(path: impl AsRef) -> Result { } fn normalized_summary_path(mut summary: ConversationSummary) -> Result { - summary.path = normalized_canonical_path(&summary.path)?; + if !summary.path.as_os_str().is_empty() { + summary.path = normalized_canonical_path(summary.path)?; + } Ok(summary) } @@ -91,34 +112,84 @@ async fn get_conversation_summary_by_thread_id_reads_rollout() -> Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn get_conversation_summary_by_rollout_path_rejects_remote_thread_store() -> Result<()> { +#[tokio::test] +async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> Result<()> { let codex_home = TempDir::new()?; - std::fs::write( - codex_home.path().join("config.toml"), - r#"experimental_thread_store_endpoint = "http://127.0.0.1:1" -"#, - )?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let request_id = mcp - .send_get_conversation_summary_request(GetConversationSummaryParams::RolloutPath { - rollout_path: PathBuf::from("sessions/2025/01/02/rollout.jsonl"), + let store_id = Uuid::new_v4().to_string(); + create_config_toml_with_in_memory_thread_store(codex_home.path(), &store_id)?; + let store = InMemoryThreadStore::for_id(store_id.clone()); + let _in_memory_store = InMemoryThreadStoreId { store_id }; + let thread_id = ThreadId::from_string("00000000-0000-4000-8000-000000000125")?; + store + .create_thread(CreateThreadParams { + thread_id, + forked_from_id: None, + source: SessionSource::Cli, + thread_source: None, + base_instructions: BaseInstructions::default(), + dynamic_tools: Vec::new(), + metadata: ThreadPersistenceMetadata { + cwd: None, + model_provider: "test-provider".to_string(), + memory_mode: ThreadMemoryMode::Disabled, + }, + event_persistence_mode: ThreadEventPersistenceMode::default(), }) .await?; - let error: JSONRPCError = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(request_id)), - ) - .await??; - assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); - assert_eq!( - error.error.message, - "rollout path queries are only supported with the local thread store" - ); + let loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .loader_overrides(loader_overrides.clone()) + .build() + .await?; + let client = in_process::start(InProcessStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: Arc::new(config), + cli_overrides: Vec::new(), + loader_overrides, + cloud_requirements: CloudRequirementsLoader::default(), + thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), + feedback: CodexFeedback::new(), + log_db: None, + state_db: None, + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), + config_warnings: Vec::new(), + session_source: SessionSource::Cli, + enable_codex_api_key_env: false, + initialize: InitializeParams { + client_info: ClientInfo { + name: "codex-app-server-tests".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: true, + ..Default::default() + }), + }, + channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await?; + + let result = client + .request(ClientRequest::GetConversationSummary { + request_id: RequestId::Integer(1), + params: GetConversationSummaryParams::ThreadId { + conversation_id: thread_id, + }, + }) + .await? + .expect("getConversationSummary should succeed"); + let GetConversationSummaryResponse { summary } = serde_json::from_value(result)?; + + assert_eq!(summary.conversation_id, thread_id); + assert_eq!(summary.path, PathBuf::new()); + assert_eq!(summary.cwd, PathBuf::new()); + assert_eq!(summary.model_provider, "test"); + + client.shutdown().await?; Ok(()) } @@ -157,3 +228,39 @@ async fn get_conversation_summary_by_relative_rollout_path_resolves_from_codex_h assert_eq!(normalized_summary_path(received.summary)?, expected); Ok(()) } + +struct InMemoryThreadStoreId { + store_id: String, +} + +impl Drop for InMemoryThreadStoreId { + fn drop(&mut self) { + InMemoryThreadStore::remove_id(&self.store_id); + } +} + +fn create_config_toml_with_in_memory_thread_store( + codex_home: &Path, + store_id: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +experimental_thread_store = {{ type = "in_memory", id = "{store_id}" }} + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "http://127.0.0.1:1/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/analytics.rs b/codex-rs/app-server/tests/suite/v2/analytics.rs index 862721a154..c6f95af95d 100644 --- a/codex-rs/app-server/tests/suite/v2/analytics.rs +++ b/codex-rs/app-server/tests/suite/v2/analytics.rs @@ -170,6 +170,7 @@ pub(crate) fn assert_basic_thread_initialized_event( thread_id: &str, expected_model: &str, initialization_mode: &str, + expected_thread_source: &str, ) { assert_eq!(event["event_params"]["thread_id"], thread_id); assert_eq!( @@ -186,7 +187,10 @@ pub(crate) fn assert_basic_thread_initialized_event( ); 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"]["thread_source"], + expected_thread_source + ); assert_eq!( event["event_params"]["subagent_source"], serde_json::Value::Null diff --git a/codex-rs/app-server/tests/suite/v2/attestation.rs b/codex-rs/app-server/tests/suite/v2/attestation.rs new file mode 100644 index 0000000000..d0565e2571 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/attestation.rs @@ -0,0 +1,194 @@ +use anyhow::Result; +use anyhow::bail; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::AttestationGenerateResponse; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_config::types::AuthCredentialsStoreMode; +use core_test_support::responses; +use core_test_support::responses::WebSocketConnectionConfig; +use core_test_support::responses::start_websocket_server_with_headers; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::Duration; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60); +const ATTESTATION_HEADER: &str = "v1.integration-test"; +const APP_SERVER_ATTESTATION_HEADER: &str = r#"{"v":1,"s":0,"t":"v1.integration-test"}"#; + +#[tokio::test] +async fn attestation_generate_round_trip_adds_header_to_responses_websocket_handshake() -> Result<()> +{ + skip_if_no_network!(Ok(())); + + let websocket_server = start_websocket_server_with_headers(vec![ + // App-server refreshes `/models` over HTTP during thread startup. It points at the same + // local test base URL, so let that non-websocket probe consume one connection before the + // websocket handshake under test arrives. + WebSocketConnectionConfig { + requests: Vec::new(), + response_headers: Vec::new(), + accept_delay: None, + close_after_requests: true, + }, + WebSocketConnectionConfig { + requests: vec![ + vec![ + responses::ev_response_created("warm-1"), + responses::ev_completed("warm-1"), + ], + vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ], + ], + response_headers: Vec::new(), + accept_delay: None, + close_after_requests: true, + }, + ]) + .await; + + let codex_home = TempDir::new()?; + create_chatgpt_websocket_config( + codex_home.path(), + &websocket_server.uri().replacen("ws://", "http://", 1), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt").plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let initialized = timeout( + DEFAULT_READ_TIMEOUT, + mcp.initialize_with_capabilities( + ClientInfo { + name: "codex_desktop".to_string(), + title: Some("Codex Desktop".to_string()), + version: "0.1.0".to_string(), + }, + Some(InitializeCapabilities { + experimental_api: true, + request_attestation: true, + opt_out_notification_methods: None, + }), + ), + ) + .await??; + let JSONRPCMessage::Response(_) = initialized else { + bail!("expected initialize response, got {initialized:?}"); + }; + + let thread_request_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_request_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_response)?; + + let turn_request_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_request_id)), + ) + .await??; + let _: TurnStartResponse = to_response(turn_response)?; + + let mut attestation_requests = 0; + timeout(DEFAULT_READ_TIMEOUT, async { + loop { + match mcp.read_next_message().await? { + JSONRPCMessage::Request(request) => { + let request = ServerRequest::try_from(request)?; + let ServerRequest::AttestationGenerate { request_id, .. } = request else { + bail!("expected attestation/generate request, got {request:?}"); + }; + attestation_requests += 1; + mcp.send_response( + request_id, + serde_json::to_value(AttestationGenerateResponse { + token: ATTESTATION_HEADER.to_string(), + })?, + ) + .await?; + } + JSONRPCMessage::Notification(notification) + if notification.method == "turn/completed" => + { + break Ok(()); + } + _ => {} + } + } + }) + .await??; + assert!(attestation_requests > 0); + + assert!( + websocket_server + .wait_for_handshakes(/*expected*/ 1, DEFAULT_READ_TIMEOUT) + .await + ); + let handshake = websocket_server.single_handshake(); + assert_eq!( + handshake.header("x-oai-attestation").as_deref(), + Some(APP_SERVER_ATTESTATION_HEADER) + ); + + websocket_server.shutdown().await; + Ok(()) +} + +fn create_chatgpt_websocket_config(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock ChatGPT provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +requires_openai_auth = true +supports_websockets = true +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index 6581c1467a..cf76b9d573 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -175,7 +175,7 @@ async fn websocket_transport_rejects_missing_and_invalid_capability_tokens() -> ]; let (mut process, bind_addr) = - spawn_websocket_server_with_args(codex_home.path(), "ws://127.0.0.1:0", &auth_args).await?; + spawn_websocket_server_with_args(codex_home.path(), "ws://0.0.0.0:0", &auth_args).await?; assert_websocket_connect_rejected(bind_addr, /*bearer_token*/ None).await?; assert_websocket_connect_rejected(bind_addr, Some("wrong-token")).await?; @@ -322,24 +322,23 @@ async fn websocket_transport_rejects_short_signed_bearer_secret_configuration() } #[tokio::test] -async fn websocket_transport_allows_unauthenticated_non_loopback_startup_by_default() -> Result<()> -{ +async fn websocket_transport_rejects_unauthenticated_non_loopback_startup() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri(), "never")?; - let (mut process, bind_addr) = - spawn_websocket_server_with_args(codex_home.path(), "ws://0.0.0.0:0", &[]).await?; - - let mut ws = connect_websocket(bind_addr).await?; - send_initialize_request(&mut ws, /*id*/ 1, "ws_non_loopback_default_client").await?; - let init = read_response_for_id(&mut ws, /*id*/ 1).await?; - assert_eq!(init.id, RequestId::Integer(1)); - - process - .kill() - .await - .context("failed to stop websocket app-server process")?; + let output = + run_websocket_server_to_completion_with_args(codex_home.path(), "ws://0.0.0.0:0", &[]) + .await?; + assert!( + !output.status.success(), + "unauthenticated non-loopback listener should fail websocket server startup" + ); + let stderr = String::from_utf8(output.stderr).context("stderr should be valid utf-8")?; + assert!( + stderr.contains("refusing to start non-loopback websocket listener"), + "unexpected stderr: {stderr}" + ); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs index 591b70af09..4b708d4ab3 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs @@ -133,6 +133,34 @@ async fn websocket_transport_second_sigterm_forces_exit_while_turn_running() -> Ok(()) } +#[tokio::test] +async fn websocket_transport_repeated_sighup_keeps_waiting_for_running_turn() -> Result<()> { + let GracefulCtrlCFixture { + _codex_home, + _server, + mut process, + mut ws, + } = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?; + + send_sighup(&process)?; + assert_process_does_not_exit_within(&mut process, Duration::from_millis(300)).await?; + + send_sighup(&process)?; + assert_process_does_not_exit_within(&mut process, Duration::from_millis(300)).await?; + + let status = wait_for_process_exit_within( + &mut process, + Duration::from_secs(10), + "timed out waiting for graceful repeated SIGHUP restart shutdown", + ) + .await?; + assert!(status.success(), "expected graceful exit, got {status}"); + + expect_websocket_disconnect(&mut ws).await?; + + Ok(()) +} + struct GracefulCtrlCFixture { _codex_home: TempDir, _server: wiremock::MockServer, @@ -236,6 +264,10 @@ fn send_sigterm(process: &Child) -> Result<()> { send_signal(process, "-TERM") } +fn send_sighup(process: &Child) -> Result<()> { + send_signal(process, "-HUP") +} + fn send_signal(process: &Child, signal: &str) -> Result<()> { let pid = process .id() diff --git a/codex-rs/app-server/tests/suite/v2/device_key.rs b/codex-rs/app-server/tests/suite/v2/device_key.rs deleted file mode 100644 index f8a4d0cf67..0000000000 --- a/codex-rs/app-server/tests/suite/v2/device_key.rs +++ /dev/null @@ -1,119 +0,0 @@ -use super::connection_handling_websocket::connect_websocket; -use super::connection_handling_websocket::create_config_toml; -use super::connection_handling_websocket::read_error_for_id; -use super::connection_handling_websocket::read_response_for_id; -use super::connection_handling_websocket::send_initialize_request; -use super::connection_handling_websocket::send_request; -use super::connection_handling_websocket::spawn_websocket_server; -use anyhow::Result; -use app_test_support::McpProcess; -use app_test_support::create_mock_responses_server_sequence_unchecked; -use codex_app_server_protocol::RequestId; -use pretty_assertions::assert_eq; -use serde_json::json; -use tempfile::TempDir; -use tokio::time::Duration; -use tokio::time::timeout; - -#[cfg(any(target_os = "macos", windows))] -const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60); -#[cfg(not(any(target_os = "macos", windows)))] -const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); - -async fn initialized_mcp(codex_home: &TempDir) -> Result { - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - Ok(mcp) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn device_key_create_rejects_empty_account_user_id() -> Result<()> { - let codex_home = TempDir::new()?; - let mut mcp = initialized_mcp(&codex_home).await?; - - let request_id = mcp - .send_raw_request( - "device/key/create", - Some(json!({ - "accountUserId": "", - "clientId": "cli_123", - })), - ) - .await?; - let error = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(request_id)), - ) - .await??; - - assert_eq!(error.error.code, -32600); - assert_eq!( - error.error.message, - "invalid device key payload: accountUserId must not be empty" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn device_key_methods_are_rejected_over_websocket() -> Result<()> { - let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; - let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), &server.uri(), "never")?; - - let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; - let mut ws = connect_websocket(bind_addr).await?; - send_initialize_request(&mut ws, /*id*/ 1, "device_key_ws_test").await?; - let initialize_response = read_response_for_id(&mut ws, /*id*/ 1).await?; - assert_eq!(initialize_response.id, RequestId::Integer(1)); - - let cases = [ - ( - "device/key/create", - json!({ - "accountUserId": "acct_123", - "clientId": "cli_123", - }), - ), - ( - "device/key/public", - json!({ - "keyId": "device-key-123", - }), - ), - ( - "device/key/sign", - json!({ - "keyId": "device-key-123", - "payload": { - "type": "remoteControlClientConnection", - "nonce": "nonce-123", - "audience": "remote_control_client_websocket", - "sessionId": "wssess_123", - "targetOrigin": "https://chatgpt.com", - "targetPath": "/api/codex/remote/control/client", - "accountUserId": "acct_123", - "clientId": "cli_123", - "tokenExpiresAt": 4_102_444_800i64, - "tokenSha256Base64url": "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU", - "scopes": ["remote_control_controller_websocket"], - }, - }), - ), - ]; - - for (index, (method, params)) in cases.into_iter().enumerate() { - let id = 2 + index as i64; - send_request(&mut ws, method, id, Some(params)).await?; - let error = read_error_for_id(&mut ws, id).await?; - - assert_eq!(error.error.code, -32600); - assert_eq!( - error.error.message, - format!("{method} is not available over remote transports") - ); - } - - process.kill().await?; - Ok(()) -} diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs index 7ee21a2068..b357e139db 100644 --- a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -239,6 +239,46 @@ async fn thread_start_rejects_hidden_dynamic_tools_without_namespace() -> Result Ok(()) } +#[tokio::test] +async fn thread_start_rejects_dynamic_tools_not_supported_by_responses() -> Result<()> { + let server = MockServer::start().await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let dynamic_tool = DynamicToolSpec { + namespace: Some("codex.app".to_string()), + name: "lookup.ticket".to_string(), + description: "Invalid dynamic tool".to_string(), + input_schema: json!({ + "type": "object", + "properties": {}, + "additionalProperties": false, + }), + defer_loading: false, + }; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + dynamic_tools: Some(vec![dynamic_tool]), + ..Default::default() + }) + .await?; + let error = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(thread_req)), + ) + .await??; + assert_eq!(error.error.code, -32600); + assert!(error.error.message.contains("Responses API")); + assert!(error.error.message.contains("lookup.ticket")); + + Ok(()) +} + /// Exercises the full dynamic tool call path (server request, client response, model output). #[tokio::test] async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Result<()> { diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index 9ac0dc3e21..4096e3d96f 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -36,6 +36,7 @@ async fn mock_experimental_method_requires_experimental_api_capability() -> Resu default_client_info(), Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), ) @@ -66,6 +67,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R default_client_info(), Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), ) @@ -103,6 +105,7 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result default_client_info(), Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), ) @@ -136,6 +139,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result< default_client_info(), Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), ) @@ -177,6 +181,7 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul default_client_info(), Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), ) @@ -214,6 +219,7 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa default_client_info(), Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), ) @@ -250,6 +256,7 @@ async fn thread_start_granular_approval_policy_requires_experimental_api_capabil default_client_info(), Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), ) diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index a186485df6..d50683cf80 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -382,7 +382,7 @@ async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() - ); assert!( error.message.contains( - "apps, memories, plugins, remote_control, tool_search, tool_suggest, tool_call_mcp_elicitation" + "apps, memories, mentions_v2, plugins, remote_control, tool_search, tool_suggest, tool_call_mcp_elicitation" ), "{}", error.message diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index e63aad9da4..f5f74c0231 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -155,7 +155,10 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl assert_eq!(notification.method, "externalAgentConfig/import/completed"); let request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, diff --git a/codex-rs/app-server/tests/suite/v2/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index f80d59d96d..15675257fd 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -11,6 +11,7 @@ use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::HookHandlerType; use codex_app_server_protocol::HookMetadata; use codex_app_server_protocol::HookSource; +use codex_app_server_protocol::HookTrustStatus; use codex_app_server_protocol::HooksListEntry; use codex_app_server_protocol::HooksListParams; use codex_app_server_protocol::HooksListResponse; @@ -26,11 +27,45 @@ use codex_protocol::config_types::TrustLevel; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::skip_if_windows; use pretty_assertions::assert_eq; +use serde::Serialize; use tempfile::TempDir; use tokio::time::timeout; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); +#[derive(Serialize)] +struct NormalizedHookIdentity { + event_name: &'static str, + #[serde(flatten)] + group: codex_config::MatcherGroup, +} + +fn command_hook_hash( + event_name: &'static str, + matcher: Option<&str>, + command: &str, + timeout_sec: u64, + status_message: Option<&str>, +) -> String { + let identity = NormalizedHookIdentity { + event_name, + group: codex_config::MatcherGroup { + matcher: matcher.map(ToOwned::to_owned), + hooks: vec![codex_config::HookHandlerConfig::Command { + command: command.to_string(), + command_windows: None, + timeout_sec: Some(timeout_sec), + r#async: false, + status_message: status_message.map(ToOwned::to_owned), + }], + }, + }; + let Ok(value) = codex_config::TomlValue::try_from(identity) else { + unreachable!("normalized hook identity should serialize to TOML"); + }; + codex_config::version_for_toml(&value) +} + fn write_user_hook_config(codex_home: &std::path::Path) -> Result<()> { std::fs::write( codex_home.join("config.toml"), @@ -72,6 +107,29 @@ enabled = true Ok(()) } +fn write_project_hook_config(dot_codex_folder: &std::path::Path, command: &str) -> Result<()> { + std::fs::create_dir_all(dot_codex_folder)?; + std::fs::write( + dot_codex_folder.join("config.toml"), + format!( + r#"[features] +hooks = true + +[hooks] + +[[hooks.PreToolUse]] +matcher = "Bash" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "{command}" +timeout = 5 +"# + ), + )?; + Ok(()) +} + #[tokio::test] async fn hooks_list_shows_discovered_hook() -> Result<()> { let codex_home = TempDir::new()?; @@ -113,6 +171,14 @@ async fn hooks_list_shows_discovered_hook() -> Result<()> { display_order: 0, enabled: true, is_managed: false, + current_hash: command_hook_hash( + "pre_tool_use", + Some("Bash"), + "python3 /tmp/listed-hook.py", + /*timeout_sec*/ 5, + Some("running listed hook"), + ), + trust_status: HookTrustStatus::Untrusted, }], warnings: Vec::new(), errors: Vec::new(), @@ -183,6 +249,14 @@ async fn hooks_list_shows_discovered_plugin_hook() -> Result<()> { display_order: 0, enabled: true, is_managed: false, + current_hash: command_hook_hash( + "pre_tool_use", + Some("Bash"), + "echo plugin hook", + /*timeout_sec*/ 7, + Some("running plugin hook"), + ), + trust_status: HookTrustStatus::Untrusted, }], warnings: Vec::new(), errors: Vec::new(), @@ -300,6 +374,14 @@ timeout = 5 display_order: 0, enabled: true, is_managed: false, + current_hash: command_hook_hash( + "pre_tool_use", + Some("Bash"), + "echo project hook", + /*timeout_sec*/ 5, + /*status_message*/ None, + ), + trust_status: HookTrustStatus::Untrusted, }], warnings: Vec::new(), errors: Vec::new(), @@ -309,6 +391,88 @@ timeout = 5 Ok(()) } +#[tokio::test] +async fn hooks_list_uses_root_repo_hooks_for_linked_worktrees() -> Result<()> { + let codex_home = TempDir::new()?; + let workspace = TempDir::new()?; + let repo_root = workspace.path().join("repo"); + let worktree_root = workspace.path().join("worktree"); + let worktree_git_dir = repo_root.join(".git/worktrees/feature-x"); + + std::fs::create_dir_all(&worktree_git_dir)?; + std::fs::create_dir_all(&worktree_root)?; + std::fs::write( + worktree_root.join(".git"), + format!("gitdir: {}\n", worktree_git_dir.display()), + )?; + write_project_hook_config(&repo_root.join(".codex"), "echo root hook")?; + write_project_hook_config(&worktree_root.join(".codex"), "echo worktree hook")?; + set_project_trust_level(codex_home.path(), &repo_root, TrustLevel::Trusted)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let list_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![repo_root.clone(), worktree_root.clone()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + let repo_hook = data[0].hooks[0].clone(); + let worktree_hook = data[1].hooks[0].clone(); + let repo_config_path = + AbsolutePathBuf::from_absolute_path(repo_root.join(".codex/config.toml"))?; + + assert_eq!(repo_hook.command.as_deref(), Some("echo root hook")); + assert_eq!(worktree_hook.command.as_deref(), Some("echo root hook")); + assert_eq!(repo_hook.key, worktree_hook.key); + assert_eq!(repo_hook.source_path, repo_config_path); + assert_eq!(worktree_hook.source_path, repo_config_path); + + let write_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + edits: vec![ConfigEdit { + key_path: "hooks.state".to_string(), + value: serde_json::json!({ + repo_hook.key.clone(): { + "trusted_hash": repo_hook.current_hash.clone() + } + }), + merge_strategy: MergeStrategy::Upsert, + }], + file_path: None, + expected_version: None, + reload_user_config: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?; + + let list_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![worktree_root], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + assert_eq!(data[0].hooks[0].trust_status, HookTrustStatus::Trusted); + + Ok(()) +} + #[tokio::test] async fn config_batch_write_toggles_user_hook() -> Result<()> { let codex_home = TempDir::new()?; @@ -408,6 +572,254 @@ async fn config_batch_write_toggles_user_hook() -> Result<()> { Ok(()) } +#[tokio::test] +async fn config_batch_write_updates_hook_trust_for_loaded_session() -> Result<()> { + skip_if_windows!(Ok(())); + + let responses = vec![ + create_final_assistant_message_sse_response("Warmup")?, + create_final_assistant_message_sse_response("Untrusted turn")?, + create_final_assistant_message_sse_response("Trusted turn")?, + create_final_assistant_message_sse_response("Modified turn")?, + ]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + let codex_home = TempDir::new()?; + let hook_script_path = codex_home.path().join("user_prompt_submit_hook.py"); + let hook_log_path = codex_home.path().join("user_prompt_submit_hook_log.jsonl"); + std::fs::write( + &hook_script_path, + format!( + r#"import json +from pathlib import Path +import sys + +payload = json.load(sys.stdin) +with Path(r"{hook_log_path}").open("a", encoding="utf-8") as handle: + handle.write(json.dumps(payload) + "\n") +"#, + hook_log_path = hook_log_path.display(), + ), + )?; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 + +[hooks] + +[[hooks.UserPromptSubmit]] + +[[hooks.UserPromptSubmit.hooks]] +type = "command" +command = "python3 {hook_script_path}" +"#, + server_uri = server.uri(), + hook_script_path = hook_script_path.display(), + ), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let hook_list_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![codex_home.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(hook_list_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + let hook = data[0].hooks[0].clone(); + assert_eq!(hook.trust_status, HookTrustStatus::Untrusted); + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(response)?; + + let first_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_id)), + ) + .await??; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + assert!(!std::fs::exists(&hook_log_path)?); + + let write_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + edits: vec![ConfigEdit { + key_path: "hooks.state".to_string(), + value: serde_json::json!({ + hook.key.clone(): { + "trusted_hash": hook.current_hash.clone() + } + }), + merge_strategy: MergeStrategy::Upsert, + }], + file_path: None, + expected_version: None, + reload_user_config: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?; + + let hook_list_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![codex_home.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(hook_list_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + let trusted_hook = &data[0].hooks[0]; + assert_eq!(trusted_hook.key, hook.key); + assert_eq!(trusted_hook.current_hash, hook.current_hash); + assert_eq!(trusted_hook.trust_status, HookTrustStatus::Trusted); + + let second_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_id)), + ) + .await??; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + assert_eq!( + std::fs::read_to_string(&hook_log_path)? + .lines() + .filter(|line| !line.is_empty()) + .count(), + 1 + ); + + let write_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + edits: vec![ConfigEdit { + key_path: "hooks.UserPromptSubmit".to_string(), + value: serde_json::json!([{ + "hooks": [{ + "type": "command", + "command": format!("python3 {}", hook_script_path.display()), + "statusMessage": "modified hook", + }], + }]), + merge_strategy: MergeStrategy::Replace, + }], + file_path: None, + expected_version: None, + reload_user_config: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?; + + let hook_list_id = mcp + .send_hooks_list_request(HooksListParams { + cwds: vec![codex_home.path().to_path_buf()], + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(hook_list_id)), + ) + .await??; + let HooksListResponse { data } = to_response(response)?; + let modified_hook = &data[0].hooks[0]; + assert_eq!(modified_hook.key, hook.key); + assert_ne!(modified_hook.current_hash, hook.current_hash); + assert_eq!(modified_hook.trust_status, HookTrustStatus::Modified); + + let third_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "third turn".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(third_turn_id)), + ) + .await??; + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + assert_eq!( + std::fs::read_to_string(&hook_log_path)? + .lines() + .filter(|line| !line.is_empty()) + .count(), + 1 + ); + Ok(()) +} + #[tokio::test] async fn config_batch_write_disables_hook_for_loaded_session() -> Result<()> { skip_if_windows!(Ok(())); @@ -482,6 +894,29 @@ command = "python3 {hook_script_path}" let hook = &data[0].hooks[0]; assert_eq!(hook.enabled, true); + let write_id = mcp + .send_config_batch_write_request(ConfigBatchWriteParams { + edits: vec![ConfigEdit { + key_path: "hooks.state".to_string(), + value: serde_json::json!({ + hook.key.clone(): { + "trusted_hash": hook.current_hash.clone() + } + }), + merge_strategy: MergeStrategy::Upsert, + }], + file_path: None, + expected_version: None, + reload_user_config: true, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(write_id)), + ) + .await??; + let _: codex_app_server_protocol::ConfigWriteResponse = to_response(response)?; + let thread_start_id = mcp .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index 165160468f..3d3a473fab 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -62,6 +62,33 @@ async fn initialize_uses_client_info_name_as_originator() -> Result<()> { Ok(()) } +#[tokio::test] +async fn initialize_probe_does_not_override_originator() -> Result<()> { + let responses = Vec::new(); + let server = create_mock_responses_server_sequence_unchecked(responses).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + let message = timeout( + DEFAULT_READ_TIMEOUT, + mcp.initialize_with_client_info(ClientInfo { + name: "codex_app_server_daemon".to_string(), + title: Some("Codex App Server Daemon".to_string()), + version: "0.1.0".to_string(), + }), + ) + .await??; + + let JSONRPCMessage::Response(response) = message else { + anyhow::bail!("expected initialize response, got {message:?}"); + }; + let InitializeResponse { user_agent, .. } = to_response::(response)?; + + assert!(user_agent.starts_with("codex_cli_rs/")); + Ok(()) +} + #[tokio::test] async fn initialize_respects_originator_override_env_var() -> Result<()> { let responses = Vec::new(); @@ -158,6 +185,7 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu }, Some(InitializeCapabilities { experimental_api: true, + request_attestation: false, opt_out_notification_methods: Some(vec!["thread/started".to_string()]), }), ), diff --git a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs index 3b1a495576..a51f4bbd4e 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_resource.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_resource.rs @@ -204,6 +204,7 @@ async fn mcp_resource_read_returns_error_for_unknown_thread() -> Result<()> { thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, + state_db: None, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source: SessionSource::Cli, diff --git a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs index 141761e88a..b805f75ba7 100644 --- a/codex-rs/app-server/tests/suite/v2/mcp_tool.rs +++ b/codex-rs/app-server/tests/suite/v2/mcp_tool.rs @@ -65,6 +65,9 @@ const TEST_TOOL_NAME: &str = "echo_tool"; const LARGE_RESPONSE_MESSAGE: &str = "large"; const ELICITATION_TRIGGER_MESSAGE: &str = "confirm"; const ELICITATION_MESSAGE: &str = "Allow this request?"; +const URL_ELICITATION_TRIGGER_MESSAGE: &str = "auth"; +const URL_ELICITATION_MESSAGE: &str = "Sign in to GitHub to continue."; +const URL_ELICITATION_URL: &str = "https://github.example/login/device"; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_server_tool_call_returns_tool_result() -> Result<()> { @@ -294,6 +297,109 @@ url = "{mcp_server_url}/mcp" Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_server_tool_call_forwards_url_elicitation() -> Result<()> { + let responses_server = responses::start_mock_server().await; + let (mcp_server_url, mcp_server_handle) = start_mcp_server().await?; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &responses_server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 1024, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + + let config_path = codex_home.path().join("config.toml"); + let mut config_toml = std::fs::read_to_string(&config_path)?; + config_toml.push_str(&format!( + r#" +[mcp_servers.{TEST_SERVER_NAME}] +url = "{mcp_server_url}/mcp" +"# + )); + std::fs::write(config_path, config_toml)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + approval_policy: Some(codex_app_server_protocol::AskForApproval::UnlessTrusted), + ..Default::default() + }) + .await?; + let thread_start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_start_resp)?; + + let tool_call_request_id = mcp + .send_mcp_server_tool_call_request(McpServerToolCallParams { + thread_id: thread.id.clone(), + server: TEST_SERVER_NAME.to_string(), + tool: TEST_TOOL_NAME.to_string(), + arguments: Some(json!({ + "message": URL_ELICITATION_TRIGGER_MESSAGE, + })), + meta: None, + }) + .await?; + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::McpServerElicitationRequest { request_id, params } = server_req else { + panic!("expected McpServerElicitationRequest request, got: {server_req:?}"); + }; + assert_eq!( + params, + McpServerElicitationRequestParams { + thread_id: thread.id, + turn_id: None, + server_name: TEST_SERVER_NAME.to_string(), + request: McpServerElicitationRequest::Url { + meta: None, + message: URL_ELICITATION_MESSAGE.to_string(), + url: URL_ELICITATION_URL.to_string(), + elicitation_id: "github-auth-123".to_string(), + }, + } + ); + + mcp.send_response( + request_id, + serde_json::to_value(McpServerElicitationRequestResponse { + action: McpServerElicitationAction::Accept, + content: None, + meta: None, + })?, + ) + .await?; + + let tool_call_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(tool_call_request_id)), + ) + .await??; + let response: McpServerToolCallResponse = to_response(tool_call_response)?; + assert_eq!(response.content.len(), 1); + assert_eq!(response.content[0].get("type"), Some(&json!("text"))); + assert_eq!(response.content[0].get("text"), Some(&json!("accepted"))); + + mcp_server_handle.abort(); + let _ = mcp_server_handle.await; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_tool_call_completion_notification_contains_truncated_large_result() -> Result<()> { let call_id = "call-large-mcp"; @@ -528,6 +634,28 @@ impl ServerHandler for ToolAppsMcpServer { return Ok(CallToolResult::success(vec![Content::text(output)])); } + if message == URL_ELICITATION_TRIGGER_MESSAGE { + let result = context + .peer + .create_elicitation(CreateElicitationRequestParams::UrlElicitationParams { + meta: None, + message: URL_ELICITATION_MESSAGE.to_string(), + url: URL_ELICITATION_URL.to_string(), + elicitation_id: "github-auth-123".to_string(), + }) + .await + .map_err(|err| rmcp::ErrorData::internal_error(err.to_string(), None))?; + let output = match result.action { + ElicitationAction::Accept => { + assert_eq!(result.content, Some(json!({}))); + "accepted" + } + ElicitationAction::Decline => "declined", + ElicitationAction::Cancel => "cancelled", + }; + return Ok(CallToolResult::success(vec![Content::text(output)])); + } + let mut result = CallToolResult::structured(json!({ "echoed": message, "threadId": thread_id, diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index a951257cc2..642be8ad4a 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -1,6 +1,7 @@ mod account; mod analytics; mod app_list; +mod attestation; mod client_metadata; mod collaboration_mode_list; #[cfg(unix)] @@ -10,7 +11,6 @@ mod config_rpc; mod connection_handling_websocket; #[cfg(unix)] mod connection_handling_websocket_unix; -mod device_key; mod dynamic_tools; mod experimental_api; mod experimental_feature_list; @@ -35,6 +35,7 @@ mod plugin_list; mod plugin_read; mod plugin_share; mod plugin_uninstall; +mod process_exec; mod rate_limits; mod realtime_conversation; #[cfg(debug_assertions)] diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs index 830ab0f783..e2039d333a 100644 --- a/codex-rs/app-server/tests/suite/v2/model_list.rs +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -9,6 +9,7 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::Model; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; +use codex_app_server_protocol::ModelServiceTier; use codex_app_server_protocol::ModelUpgradeInfo; use codex_app_server_protocol::ReasoningEffortOption; use codex_app_server_protocol::RequestId; @@ -51,6 +52,15 @@ fn model_from_preset(preset: &ModelPreset) -> Model { // todo(sayan): fix, maybe make roundtrip use ModelInfo only supports_personality: false, additional_speed_tiers: preset.additional_speed_tiers.clone(), + service_tiers: preset + .service_tiers + .iter() + .map(|service_tier| ModelServiceTier { + id: service_tier.id.clone(), + name: service_tier.name.clone(), + description: service_tier.description.clone(), + }) + .collect(), is_default: preset.is_default, } } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 2b2f781368..6adcd92195 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -153,8 +153,14 @@ async fn plugin_install_rejects_multiple_install_sources() -> Result<()> { } #[tokio::test] -async fn plugin_install_rejects_remote_marketplace_when_remote_plugin_is_disabled() -> Result<()> { +async fn plugin_install_rejects_remote_marketplace_when_plugins_are_disabled() -> Result<()> { let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = false +"#, + )?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 86fb78bae1..0b2200a9d5 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -9,9 +9,11 @@ use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginListMarketplaceKind; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; +use codex_app_server_protocol::PluginShareDiscoverability; use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::RequestId; @@ -90,6 +92,7 @@ async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Resul let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, }) .await?; @@ -197,7 +200,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ valid_repo_root .path() .join("plugins/valid-plugin/.codex-plugin/plugin.json"), - r#"{"name":"valid-plugin"}"#, + r#"{"name":"valid-plugin","keywords":["api-key","developer tools"]}"#, )?; std::fs::write(invalid_marketplace_path.as_path(), "{not json")?; @@ -218,6 +221,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ AbsolutePathBuf::try_from(valid_repo_root.path())?, AbsolutePathBuf::try_from(invalid_repo_root.path())?, ]), + marketplace_kinds: None, }) .await?; @@ -236,7 +240,10 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ interface: None, plugins: vec![PluginSummary { id: "valid-plugin@valid-marketplace".to_string(), + remote_plugin_id: None, + local_version: None, name: "valid-plugin".to_string(), + share_context: None, source: PluginSource::Local { path: valid_plugin_path, }, @@ -246,6 +253,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ auth_policy: PluginAuthPolicy::OnInstall, availability: codex_app_server_protocol::PluginAvailability::Available, interface: None, + keywords: vec!["api-key".to_string(), "developer tools".to_string()], }], }] ); @@ -327,6 +335,7 @@ async fn plugin_list_returns_empty_when_workspace_codex_plugins_disabled() -> Re let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, }) .await?; @@ -418,6 +427,7 @@ async fn plugin_list_reuses_cached_workspace_codex_plugins_setting() -> Result<( let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, }) .await?; @@ -501,6 +511,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, }) .await?; @@ -520,7 +531,10 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab plugins: vec![ PluginSummary { id: "valid-plugin@alternate-marketplace".to_string(), + remote_plugin_id: None, + local_version: None, name: "valid-plugin".to_string(), + share_context: None, source: PluginSource::Local { path: valid_plugin_path, }, @@ -548,10 +562,14 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab screenshots: Vec::new(), screenshot_urls: Vec::new(), }), + keywords: Vec::new(), }, PluginSummary { id: "missing-plugin@alternate-marketplace".to_string(), + remote_plugin_id: None, + local_version: None, name: "missing-plugin".to_string(), + share_context: None, source: PluginSource::Local { path: AbsolutePathBuf::try_from( repo_root.path().join("plugins/missing-plugin"), @@ -563,6 +581,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab auth_policy: PluginAuthPolicy::OnInstall, availability: codex_app_server_protocol::PluginAvailability::Available, interface: None, + keywords: Vec::new(), }, ], }] @@ -603,7 +622,10 @@ async fn plugin_list_accepts_omitted_cwds() -> Result<()> { timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) .await?; let response: JSONRPCResponse = timeout( @@ -615,6 +637,79 @@ async fn plugin_list_accepts_omitted_cwds() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_list_returns_share_context_for_shared_local_plugin() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let plugin_root = repo_root.path().join("plugins/demo-plugin"); + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + write_plugins_enabled_config(codex_home.path())?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "demo-plugin", + "source": { + "source": "local", + "path": "./plugins/demo-plugin" + } + } + ] +}"#, + )?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"demo-plugin","version":"1.2.3"}"#, + )?; + write_plugin_share_local_path_mapping( + codex_home.path(), + "plugins_123", + &AbsolutePathBuf::try_from(plugin_root)?, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + let plugin = response + .marketplaces + .iter() + .flat_map(|marketplace| marketplace.plugins.iter()) + .find(|plugin| plugin.name == "demo-plugin") + .expect("expected demo-plugin entry"); + assert_eq!(plugin.remote_plugin_id, None); + assert_eq!(plugin.local_version.as_deref(), Some("1.2.3")); + let share_context = plugin + .share_context + .as_ref() + .expect("expected share context"); + assert_eq!(share_context.remote_plugin_id, "plugins_123"); + assert_eq!(share_context.remote_version, None); + assert_eq!(share_context.discoverability, None); + assert_eq!(share_context.share_url, None); + assert_eq!(share_context.creator_account_user_id, None); + assert_eq!(share_context.creator_name, None); + assert_eq!(share_context.share_principals, None); + Ok(()) +} + #[tokio::test] async fn plugin_list_includes_install_and_enabled_state_from_config() -> Result<()> { let codex_home = TempDir::new()?; @@ -674,6 +769,7 @@ enabled = false let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, }) .await?; @@ -830,6 +926,7 @@ enabled = false AbsolutePathBuf::try_from(workspace_enabled.path())?, AbsolutePathBuf::try_from(workspace_default.path())?, ]), + marketplace_kinds: None, }) .await?; @@ -913,6 +1010,7 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, }) .await?; @@ -1025,6 +1123,7 @@ async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> { let request_id = mcp .send_plugin_list_request(PluginListParams { cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, }) .await?; @@ -1051,6 +1150,124 @@ async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_list_returns_installed_git_source_interface_from_cache() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let missing_remote_repo = repo_root.path().join("missing-remote-plugin-repo"); + let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo) + .unwrap() + .to_string(); + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + format!( + r#"{{ + "name": "debug", + "plugins": [ + {{ + "name": "toolkit", + "source": {{ + "source": "git-subdir", + "url": "{missing_remote_repo_url}", + "path": "plugins/toolkit" + }}, + "category": "Developer Tools" + }} + ] +}}"# + ), + )?; + let cached_plugin_root = codex_home.path().join("plugins/cache/debug/toolkit/local"); + std::fs::create_dir_all(cached_plugin_root.join(".codex-plugin"))?; + std::fs::write( + cached_plugin_root.join(".codex-plugin/plugin.json"), + r##"{ + "name": "toolkit", + "interface": { + "displayName": "Toolkit", + "shortDescription": "Search cached data", + "category": "Cached Category", + "brandColor": "#3B82F6", + "composerIcon": "./assets/icon.png", + "logo": "./assets/logo.png" + } +}"##, + )?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = true + +[plugins."toolkit@debug"] +enabled = true +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + marketplace_kinds: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + let plugin = response + .marketplaces + .iter() + .flat_map(|marketplace| marketplace.plugins.iter()) + .find(|plugin| plugin.name == "toolkit") + .expect("expected toolkit entry"); + + assert_eq!(plugin.id, "toolkit@debug"); + assert_eq!(plugin.installed, true); + assert_eq!(plugin.enabled, true); + assert_eq!( + plugin.source, + PluginSource::Git { + url: missing_remote_repo_url, + path: Some("plugins/toolkit".to_string()), + ref_name: None, + sha: None, + } + ); + let interface = plugin + .interface + .as_ref() + .expect("expected cached plugin interface"); + assert_eq!(interface.display_name.as_deref(), Some("Toolkit")); + assert_eq!( + interface.short_description.as_deref(), + Some("Search cached data") + ); + assert_eq!(interface.category.as_deref(), Some("Developer Tools")); + assert_eq!(interface.brand_color.as_deref(), Some("#3B82F6")); + let canonical_cached_plugin_root = std::fs::canonicalize(&cached_plugin_root)?; + assert_eq!( + interface.composer_icon, + Some(AbsolutePathBuf::try_from( + canonical_cached_plugin_root.join("assets/icon.png") + )?) + ); + assert_eq!( + interface.logo, + Some(AbsolutePathBuf::try_from( + canonical_cached_plugin_root.join("assets/logo.png") + )?) + ); + Ok(()) +} + #[tokio::test] async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { let codex_home = TempDir::new()?; @@ -1098,7 +1315,10 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1) .await?; let request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, @@ -1231,7 +1451,10 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles() timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, @@ -1250,11 +1473,7 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles() .into_iter() .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) .collect::>(), - vec![( - "plugins~Plugin_00000000000000000000000000000000".to_string(), - true, - true - )] + vec![("linear@chatgpt-global".to_string(), true, true)] ); wait_for_path_exists(&new_path.join(".codex-plugin/plugin.json")).await?; @@ -1295,6 +1514,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - "display_name": "Linear", "description": "Track work in Linear", "app_ids": [], + "keywords": ["issue-tracking", "project management"], "interface": { "short_description": "Plan and track work", "capabilities": ["Read", "Write"], @@ -1387,7 +1607,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) .await?; let response: JSONRPCResponse = timeout( @@ -1411,9 +1634,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - Some("ChatGPT Plugins") ); assert_eq!(remote_marketplace.plugins.len(), 1); + assert_eq!(remote_marketplace.plugins[0].id, "linear@chatgpt-global"); assert_eq!( - remote_marketplace.plugins[0].id, - "plugins~Plugin_00000000000000000000000000000000" + remote_marketplace.plugins[0].remote_plugin_id.as_deref(), + Some("plugins~Plugin_00000000000000000000000000000000") ); assert_eq!(remote_marketplace.plugins[0].name, "linear"); assert_eq!(remote_marketplace.plugins[0].source, PluginSource::Remote); @@ -1430,10 +1654,366 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - .and_then(|interface| interface.display_name.as_deref()), Some("Linear") ); + assert_eq!( + remote_marketplace.plugins[0].keywords, + vec![ + "issue-tracking".to_string(), + "project management".to_string() + ] + ); assert_eq!(response.featured_plugin_ids, Vec::::new()); Ok(()) } +#[tokio::test] +async fn plugin_list_does_not_append_global_remote_when_marketplace_kinds_are_explicit() +-> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![PluginListMarketplaceKind::Local]), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert!( + response + .marketplaces + .iter() + .all(|marketplace| marketplace.name != "chatgpt-global") + ); + wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?; + Ok(()) +} + +#[tokio::test] +async fn plugin_list_fetches_workspace_directory_kind_without_remote_plugin_flag() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let workspace_plugin_body = workspace_remote_plugin_page_body( + "plugins~Plugin_11111111111111111111111111111111", + "workspace-linear", + "Workspace Linear", + "LISTED", + /*enabled*/ None, + ); + let workspace_installed_body = workspace_remote_plugin_page_body( + "plugins~Plugin_11111111111111111111111111111111", + "workspace-linear", + "Workspace Linear", + "LISTED", + /*enabled*/ Some(false), + ); + mount_remote_plugin_list(&server, "WORKSPACE", &workspace_plugin_body).await; + mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![PluginListMarketplaceKind::WorkspaceDirectory]), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert_eq!(response.marketplaces.len(), 1); + let marketplace = &response.marketplaces[0]; + assert_eq!(marketplace.name, "workspace-directory"); + assert_eq!( + marketplace + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("Workspace Directory") + ); + assert_eq!(marketplace.plugins.len(), 1); + assert_eq!( + marketplace.plugins[0].id, + "workspace-linear@workspace-directory" + ); + assert_eq!( + marketplace.plugins[0].remote_plugin_id.as_deref(), + Some("plugins~Plugin_11111111111111111111111111111111") + ); + assert_eq!(marketplace.plugins[0].name, "workspace-linear"); + assert_eq!(marketplace.plugins[0].installed, true); + assert_eq!(marketplace.plugins[0].enabled, false); + assert!( + !server + .received_requests() + .await + .expect("wiremock should record requests") + .iter() + .any(|request| request + .url + .query() + .is_some_and(|query| query.contains("scope=GLOBAL"))) + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut shared_plugin_body: serde_json::Value = + serde_json::from_str(&workspace_remote_plugin_page_body( + "plugins~Plugin_22222222222222222222222222222222", + "shared-linear", + "Shared Linear", + "PRIVATE", + /*enabled*/ None, + ))?; + shared_plugin_body["plugins"][0]["share_principals"] = serde_json::Value::Null; + let shared_plugin_body = serde_json::to_string(&shared_plugin_body)?; + let mut workspace_installed_body: serde_json::Value = + serde_json::from_str(&workspace_remote_plugin_page_body( + "plugins~Plugin_22222222222222222222222222222222", + "shared-linear", + "Shared Linear", + "PRIVATE", + /*enabled*/ Some(true), + ))?; + let unlisted_installed_body: serde_json::Value = + serde_json::from_str(&workspace_remote_plugin_page_body( + "plugins~Plugin_33333333333333333333333333333333", + "unlisted-linear", + "Unlisted Linear", + "UNLISTED", + /*enabled*/ Some(false), + ))?; + workspace_installed_body["plugins"] + .as_array_mut() + .expect("installed plugins should be an array") + .push(unlisted_installed_body["plugins"][0].clone()); + let workspace_installed_body = serde_json::to_string(&workspace_installed_body)?; + mount_shared_workspace_plugins(&server, &shared_plugin_body).await; + mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![PluginListMarketplaceKind::SharedWithMe]), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert_eq!(response.marketplaces.len(), 2); + let marketplace = response + .marketplaces + .iter() + .find(|marketplace| marketplace.name == "workspace-shared-with-me-private") + .expect("expected private shared-with-me marketplace"); + assert_eq!( + marketplace + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("Shared with me") + ); + assert_eq!(marketplace.plugins.len(), 1); + assert_eq!( + marketplace.plugins[0].id, + "shared-linear@workspace-shared-with-me-private" + ); + assert_eq!( + marketplace.plugins[0].remote_plugin_id.as_deref(), + Some("plugins~Plugin_22222222222222222222222222222222") + ); + assert_eq!(marketplace.plugins[0].name, "shared-linear"); + assert_eq!(marketplace.plugins[0].installed, true); + assert_eq!(marketplace.plugins[0].enabled, true); + let share_context = marketplace.plugins[0] + .share_context + .as_ref() + .expect("expected share context"); + assert_eq!( + share_context.remote_plugin_id, + "plugins~Plugin_22222222222222222222222222222222" + ); + assert_eq!(share_context.remote_version.as_deref(), Some("1.2.3")); + assert_eq!( + share_context.discoverability, + Some(PluginShareDiscoverability::Private) + ); + assert_eq!( + share_context.creator_account_user_id.as_deref(), + Some("user-gavin__account-123") + ); + assert_eq!(share_context.creator_name.as_deref(), Some("Gavin")); + assert_eq!( + share_context.share_url.as_deref(), + Some("https://chatgpt.example/plugins/share/share-key-1") + ); + assert_eq!(share_context.share_principals, None); + + let marketplace = response + .marketplaces + .iter() + .find(|marketplace| marketplace.name == "workspace-shared-with-me-unlisted") + .expect("expected unlisted shared-with-me marketplace"); + assert_eq!( + marketplace + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("Shared with me (unlisted)") + ); + assert_eq!(marketplace.plugins.len(), 1); + assert_eq!( + marketplace.plugins[0].id, + "unlisted-linear@workspace-shared-with-me-unlisted" + ); + assert_eq!( + marketplace.plugins[0].remote_plugin_id.as_deref(), + Some("plugins~Plugin_33333333333333333333333333333333") + ); + assert_eq!(marketplace.plugins[0].name, "unlisted-linear"); + assert_eq!(marketplace.plugins[0].installed, true); + assert_eq!(marketplace.plugins[0].enabled, false); + let share_context = marketplace.plugins[0] + .share_context + .as_ref() + .expect("expected share context"); + assert_eq!( + share_context.remote_plugin_id, + "plugins~Plugin_33333333333333333333333333333333" + ); + assert_eq!(share_context.remote_version.as_deref(), Some("1.2.3")); + assert_eq!( + share_context.discoverability, + Some(PluginShareDiscoverability::Unlisted) + ); + wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?; + Ok(()) +} + +#[tokio::test] +async fn plugin_list_omits_shared_with_me_kind_when_plugin_sharing_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true +plugin_sharing = false +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![PluginListMarketplaceKind::SharedWithMe]), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert_eq!( + response, + PluginListResponse { + marketplaces: Vec::new(), + marketplace_load_errors: Vec::new(), + featured_plugin_ids: Vec::new(), + } + ); + wait_for_remote_plugin_request_count( + &server, + "/ps/plugins/workspace/shared", + /*expected_count*/ 0, + ) + .await?; + Ok(()) +} + #[tokio::test] async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> { let codex_home = TempDir::new()?; @@ -1539,7 +2119,10 @@ async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> { timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) .await?; let response: JSONRPCResponse = timeout( @@ -1664,7 +2247,10 @@ async fn plugin_list_remote_marketplace_replaces_local_marketplace_with_same_nam timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) .await?; let response: JSONRPCResponse = timeout( @@ -1720,7 +2306,10 @@ remote_plugin = true timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) .await?; let response: JSONRPCResponse = timeout( @@ -1753,7 +2342,10 @@ async fn plugin_list_fetches_featured_plugin_ids_without_chatgpt_auth() -> Resul timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) .await?; let response: JSONRPCResponse = timeout( @@ -1790,7 +2382,10 @@ async fn plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request() -> wait_for_featured_plugin_request_count(&server, /*expected_count*/ 1).await?; let request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) .await?; let response: JSONRPCResponse = timeout( @@ -1891,6 +2486,17 @@ async fn mount_remote_plugin_list(server: &MockServer, scope: &str, body: &str) .await; } +async fn mount_shared_workspace_plugins(server: &MockServer, body: &str) { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/workspace/shared")) + .and(query_param("limit", "200")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(server) + .await; +} + async fn mount_remote_installed_plugins(server: &MockServer, scope: &str, body: &str) { Mock::given(method("GET")) .and(path("/backend-api/ps/plugins/installed")) @@ -1912,6 +2518,62 @@ fn empty_remote_installed_plugins_body() -> &'static str { }"# } +fn workspace_remote_plugin_page_body( + remote_plugin_id: &str, + plugin_name: &str, + display_name: &str, + discoverability: &str, + enabled: Option, +) -> String { + let enabled_field = enabled + .map(|enabled| format!(r#", "enabled": {enabled}, "disabled_skill_names": []"#)) + .unwrap_or_default(); + format!( + r#"{{ + "plugins": [ + {{ + "id": "{remote_plugin_id}", + "name": "{plugin_name}", + "scope": "WORKSPACE", + "discoverability": "{discoverability}", + "creator_account_user_id": "user-gavin__account-123", + "share_url": "https://chatgpt.example/plugins/share/share-key-1", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "ENABLED", + "creator_name": "Gavin", + "share_principals": [ + {{ + "principal_type": "user", + "principal_id": "user-gavin__account-123", + "role": "owner", + "name": "Gavin" + }}, + {{ + "principal_type": "user", + "principal_id": "user-ada__account-123", + "role": "reader", + "name": "Ada" + }} + ], + "release": {{ + "version": "1.2.3", + "display_name": "{display_name}", + "description": "Track work", + "app_ids": [], + "interface": {{}}, + "skills": [] + }}{enabled_field} + }} + ], + "pagination": {{ + "limit": 50, + "next_page_token": null + }} +}}"# + ) +} + fn remote_installed_plugin_body( bundle_download_url: &str, release_version: &str, @@ -2110,3 +2772,24 @@ fn write_openai_curated_marketplace( )?; Ok(()) } + +fn write_plugin_share_local_path_mapping( + codex_home: &std::path::Path, + remote_plugin_id: &str, + plugin_path: &AbsolutePathBuf, +) -> std::io::Result<()> { + let mut local_plugin_paths_by_remote_plugin_id = serde_json::Map::new(); + local_plugin_paths_by_remote_plugin_id.insert( + remote_plugin_id.to_string(), + serde_json::to_value(plugin_path).map_err(std::io::Error::other)?, + ); + let contents = serde_json::to_string_pretty(&serde_json::json!({ + "localPluginPathsByRemotePluginId": local_plugin_paths_by_remote_plugin_id, + })) + .map_err(std::io::Error::other)?; + std::fs::create_dir_all(codex_home.join(".tmp"))?; + std::fs::write( + codex_home.join(".tmp/plugin-share-local-paths-v1.json"), + format!("{contents}\n"), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index fd082ab412..525a4234aa 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -17,11 +17,17 @@ use axum::http::Uri; use axum::http::header::AUTHORIZATION; use axum::routing::get; use codex_app_server_protocol::AppInfo; +use codex_app_server_protocol::HookEventName; +use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; +use codex_app_server_protocol::PluginShareDiscoverability; +use codex_app_server_protocol::PluginSharePrincipal; +use codex_app_server_protocol::PluginSharePrincipalRole; +use codex_app_server_protocol::PluginSharePrincipalType; use codex_app_server_protocol::PluginSkillReadParams; use codex_app_server_protocol::PluginSkillReadResponse; use codex_app_server_protocol::PluginSource; @@ -116,8 +122,74 @@ async fn plugin_read_rejects_multiple_read_sources() -> Result<()> { } #[tokio::test] -async fn plugin_read_rejects_remote_marketplace_when_remote_plugin_is_disabled() -> Result<()> { +async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_is_disabled() -> Result<()> { let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let detail_body = r#"{ + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "keywords": [], + "interface": { + "short_description": "Plan and track work", + "capabilities": [] + }, + "skills": [] + } +}"#; + let installed_body = r#"{ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + + Mock::given(method("GET")) + .and(path( + "/backend-api/ps/plugins/plugins~Plugin_00000000000000000000000000000000", + )) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(detail_body)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "GLOBAL")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(installed_body)) + .mount(&server) + .await; + let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -125,23 +197,177 @@ async fn plugin_read_rejects_remote_marketplace_when_remote_plugin_is_disabled() .send_plugin_read_request(PluginReadParams { marketplace_path: None, remote_marketplace_name: Some("chatgpt-global".to_string()), - plugin_name: "sample-plugin".to_string(), + plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(), }) .await?; - let err = timeout( + let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; + let response: PluginReadResponse = to_response(response)?; - assert_eq!(err.error.code, -32600); - assert!( - err.error - .message - .contains("remote plugin read is not enabled") + assert_eq!(response.plugin.marketplace_name, "chatgpt-global"); + assert_eq!(response.plugin.summary.id, "linear@chatgpt-global"); + assert_eq!( + response.plugin.summary.remote_plugin_id.as_deref(), + Some("plugins~Plugin_00000000000000000000000000000000") + ); + assert_eq!(response.plugin.summary.name, "linear"); + assert_eq!(response.plugin.summary.source, PluginSource::Remote); + assert_eq!(response.plugin.summary.share_context, None); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let detail_body = r#"{ + "id": "plugins~Plugin_11111111111111111111111111111111", + "name": "shared-linear", + "scope": "WORKSPACE", + "discoverability": "PRIVATE", + "creator_account_user_id": "user-gavin__account-123", + "creator_name": "Gavin", + "share_url": "https://chatgpt.example/plugins/share/share-key-1", + "share_principals": [ + { + "principal_type": "user", + "principal_id": "user-gavin__account-123", + "role": "owner", + "name": "Gavin" + }, + { + "principal_type": "user", + "principal_id": "user-ada__account-123", + "role": "reader", + "name": "Ada" + } + ], + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "version": "2.3.4", + "display_name": "Shared Linear", + "description": "Track shared work", + "app_ids": [], + "keywords": [], + "interface": {}, + "skills": [] + } +}"#; + let installed_body = r#"{ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + + Mock::given(method("GET")) + .and(path( + "/backend-api/ps/plugins/plugins~Plugin_11111111111111111111111111111111", + )) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(detail_body)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "WORKSPACE")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(installed_body)) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: None, + remote_marketplace_name: Some("workspace-shared-with-me-private".to_string()), + plugin_name: "plugins~Plugin_11111111111111111111111111111111".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginReadResponse = to_response(response)?; + + assert_eq!( + response.plugin.marketplace_name, + "workspace-shared-with-me-private" + ); + assert_eq!( + response.plugin.summary.id, + "shared-linear@workspace-shared-with-me-private" + ); + assert_eq!( + response.plugin.summary.remote_plugin_id.as_deref(), + Some("plugins~Plugin_11111111111111111111111111111111") + ); + let share_context = response + .plugin + .summary + .share_context + .as_ref() + .expect("expected share context"); + assert_eq!( + share_context.remote_plugin_id, + "plugins~Plugin_11111111111111111111111111111111" + ); + assert_eq!(share_context.remote_version.as_deref(), Some("2.3.4")); + assert_eq!( + share_context.discoverability, + Some(PluginShareDiscoverability::Private) + ); + assert_eq!( + share_context.creator_account_user_id.as_deref(), + Some("user-gavin__account-123") + ); + assert_eq!(share_context.creator_name.as_deref(), Some("Gavin")); + assert_eq!( + share_context.share_url.as_deref(), + Some("https://chatgpt.example/plugins/share/share-key-1") + ); + assert_eq!( + share_context.share_principals, + Some(vec![ + PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-gavin__account-123".to_string(), + role: PluginSharePrincipalRole::Owner, + name: "Gavin".to_string(), + }, + PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-ada__account-123".to_string(), + role: PluginSharePrincipalRole::Reader, + name: "Ada".to_string(), + }, + ]) ); - assert!(err.error.message.contains("chatgpt-global")); Ok(()) } @@ -172,6 +398,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> "display_name": "Linear", "description": "Track work in Linear", "app_ids": [], + "keywords": ["issue-tracking", "project management"], "interface": { "short_description": "Plan and track work", "capabilities": ["Read", "Write"], @@ -270,9 +497,10 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> assert_eq!(response.plugin.marketplace_name, "chatgpt-global"); assert_eq!(response.plugin.marketplace_path, None); assert_eq!(response.plugin.summary.source, PluginSource::Remote); + assert_eq!(response.plugin.summary.id, "linear@chatgpt-global"); assert_eq!( - response.plugin.summary.id, - "plugins~Plugin_00000000000000000000000000000000" + response.plugin.summary.remote_plugin_id.as_deref(), + Some("plugins~Plugin_00000000000000000000000000000000") ); assert_eq!(response.plugin.summary.name, "linear"); assert_eq!(response.plugin.summary.installed, true); @@ -281,6 +509,13 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> response.plugin.description.as_deref(), Some("Track work in Linear") ); + assert_eq!( + response.plugin.summary.keywords, + vec![ + "issue-tracking".to_string(), + "project management".to_string() + ] + ); assert_eq!(response.plugin.skills.len(), 1); assert_eq!(response.plugin.skills[0].name, "plan-work"); assert_eq!(response.plugin.skills[0].path, None); @@ -545,6 +780,348 @@ enabled = true Ok(()) } +#[tokio::test] +async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + write_plugin_marketplace( + repo_root.path(), + "codex-curated", + "demo-plugin", + "./demo-plugin", + )?; + std::fs::create_dir_all(repo_root.path().join("demo-plugin/.codex-plugin"))?; + std::fs::write( + repo_root + .path() + .join("demo-plugin/.codex-plugin/plugin.json"), + r#"{"name":"demo-plugin","version":"1.2.3"}"#, + )?; + let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?; + write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/plugins_123")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "plugins_123", + "name": "demo-plugin", + "scope": "WORKSPACE", + "discoverability": "UNLISTED", + "creator_account_user_id": "user-owner__account-123", + "creator_name": "Owner", + "share_url": "https://chatgpt.example/plugins/share/share-key-1", + "share_principals": [ + { + "principal_type": "user", + "principal_id": "user-owner__account-123", + "role": "owner", + "name": "Owner", + }, + { + "principal_type": "user", + "principal_id": "user-editor__account-123", + "role": "editor", + "name": "Editor", + }, + ], + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "version": "1.2.4", + "display_name": "Demo Plugin", + "description": "Shared local plugin", + "app_ids": [], + "keywords": [], + "interface": {}, + "skills": [] + } + }))) + .expect(1) + .mount(&server) + .await; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(AbsolutePathBuf::try_from( + repo_root.path().join(".agents/plugins/marketplace.json"), + )?), + remote_marketplace_name: None, + plugin_name: "demo-plugin".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginReadResponse = to_response(response)?; + + assert_eq!(response.plugin.summary.remote_plugin_id, None); + assert_eq!( + response.plugin.summary.local_version.as_deref(), + Some("1.2.3") + ); + let share_context = response + .plugin + .summary + .share_context + .as_ref() + .expect("expected share context"); + assert_eq!(share_context.remote_plugin_id, "plugins_123"); + assert_eq!(share_context.remote_version.as_deref(), Some("1.2.4")); + assert_eq!( + share_context.discoverability, + Some(PluginShareDiscoverability::Unlisted) + ); + assert_eq!( + share_context.share_url.as_deref(), + Some("https://chatgpt.example/plugins/share/share-key-1") + ); + assert_eq!( + share_context.creator_account_user_id.as_deref(), + Some("user-owner__account-123") + ); + assert_eq!(share_context.creator_name.as_deref(), Some("Owner")); + assert_eq!( + share_context.share_principals, + Some(vec![ + PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-owner__account-123".to_string(), + role: PluginSharePrincipalRole::Owner, + name: "Owner".to_string(), + }, + PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-editor__account-123".to_string(), + role: PluginSharePrincipalRole::Editor, + name: "Editor".to_string(), + }, + ]) + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_keeps_remote_version_when_share_principals_are_missing() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + codex_home.path(), + &format!("{}/backend-api/", server.uri()), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + write_plugin_marketplace( + repo_root.path(), + "codex-curated", + "demo-plugin", + "./demo-plugin", + )?; + std::fs::create_dir_all(repo_root.path().join("demo-plugin/.codex-plugin"))?; + std::fs::write( + repo_root + .path() + .join("demo-plugin/.codex-plugin/plugin.json"), + r#"{"name":"demo-plugin","version":"1.2.3"}"#, + )?; + let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?; + write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/plugins_123")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "id": "plugins_123", + "name": "demo-plugin", + "scope": "WORKSPACE", + "discoverability": "UNLISTED", + "creator_account_user_id": "user-owner__account-123", + "creator_name": "Owner", + "share_url": "https://chatgpt.example/plugins/share/share-key-1", + "share_principals": null, + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": { + "version": "1.2.4", + "display_name": "Demo Plugin", + "description": "Shared local plugin", + "app_ids": [], + "keywords": [], + "interface": {}, + "skills": [] + } + }))) + .expect(1) + .mount(&server) + .await; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(AbsolutePathBuf::try_from( + repo_root.path().join(".agents/plugins/marketplace.json"), + )?), + remote_marketplace_name: None, + plugin_name: "demo-plugin".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginReadResponse = to_response(response)?; + + assert_eq!(response.plugin.summary.remote_plugin_id, None); + assert_eq!( + response.plugin.summary.local_version.as_deref(), + Some("1.2.3") + ); + let share_context = response + .plugin + .summary + .share_context + .as_ref() + .expect("expected share context"); + assert_eq!(share_context.remote_plugin_id, "plugins_123"); + assert_eq!(share_context.remote_version.as_deref(), Some("1.2.4")); + assert_eq!(share_context.discoverability, None); + assert_eq!(share_context.share_url, None); + assert_eq!(share_context.creator_account_user_id, None); + assert_eq!(share_context.creator_name, None); + assert_eq!(share_context.share_principals, None); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_falls_back_to_local_share_context_without_remote_auth() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + write_plugins_enabled_config(&codex_home)?; + write_plugin_marketplace( + repo_root.path(), + "codex-curated", + "demo-plugin", + "./demo-plugin", + )?; + write_plugin_source(repo_root.path(), "demo-plugin", &[])?; + let plugin_path = AbsolutePathBuf::try_from(repo_root.path().join("demo-plugin"))?; + write_plugin_share_local_path_mapping(codex_home.path(), "plugins_123", &plugin_path)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(AbsolutePathBuf::try_from( + repo_root.path().join(".agents/plugins/marketplace.json"), + )?), + remote_marketplace_name: None, + plugin_name: "demo-plugin".to_string(), + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginReadResponse = to_response(response)?; + + assert_eq!(response.plugin.summary.remote_plugin_id, None); + assert_eq!(response.plugin.summary.local_version, None); + let share_context = response + .plugin + .summary + .share_context + .as_ref() + .expect("expected share context"); + assert_eq!(share_context.remote_plugin_id, "plugins_123"); + assert_eq!(share_context.remote_version, None); + assert_eq!(share_context.discoverability, None); + assert_eq!(share_context.share_url, None); + assert_eq!(share_context.creator_account_user_id, None); + assert_eq!(share_context.creator_name, None); + assert_eq!(share_context.share_principals, None); + Ok(()) +} + +#[tokio::test] +async fn plugin_read_fails_on_malformed_share_mapping() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + write_plugins_enabled_config(&codex_home)?; + write_plugin_marketplace( + repo_root.path(), + "codex-curated", + "demo-plugin", + "./demo-plugin", + )?; + write_plugin_source(repo_root.path(), "demo-plugin", &[])?; + std::fs::create_dir_all(codex_home.path().join(".tmp"))?; + std::fs::write( + codex_home + .path() + .join(".tmp/plugin-share-local-paths-v1.json"), + "not valid json\n", + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_read_request(PluginReadParams { + marketplace_path: Some(AbsolutePathBuf::try_from( + repo_root.path().join(".agents/plugins/marketplace.json"), + )?), + remote_marketplace_name: None, + plugin_name: "demo-plugin".to_string(), + }) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32603); + assert!( + error + .error + .message + .contains("failed to load plugin share local path mapping") + ); + Ok(()) +} + #[tokio::test] async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()> { let codex_home = TempDir::new()?; @@ -553,6 +1130,7 @@ async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()> std::fs::create_dir_all(repo_root.path().join(".git"))?; std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::create_dir_all(plugin_root.join("hooks"))?; std::fs::create_dir_all(plugin_root.join("skills/thread-summarizer"))?; std::fs::create_dir_all(plugin_root.join("skills/chatgpt-only"))?; std::fs::write( @@ -580,6 +1158,7 @@ async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()> r##"{ "name": "demo-plugin", "description": "Longer manifest description", + "keywords": ["api-key", "developer tools"], "interface": { "displayName": "Plugin Display Name", "shortDescription": "Short description for subtitle", @@ -655,12 +1234,44 @@ description: Visible only for ChatGPT "command": "demo-server" } } +}"#, + )?; + std::fs::write( + plugin_root.join("hooks/hooks.json"), + r#"{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo startup" + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "echo first" + }, + { + "type": "command", + "command": "echo second" + } + ] + } + ] + } }"#, )?; std::fs::write( codex_home.path().join("config.toml"), r#"[features] plugins = true +plugin_hooks = true [[skills.config]] name = "demo-plugin:thread-summarizer" @@ -668,6 +1279,9 @@ enabled = false [plugins."demo-plugin@codex-curated"] enabled = true + +[hooks.state."demo-plugin@codex-curated:hooks/hooks.json:pre_tool_use:0:0"] +enabled = false "#, )?; write_installed_plugin(&codex_home, "codex-curated", "demo-plugin")?; @@ -740,6 +1354,10 @@ enabled = true "Find my next action".to_string() ]) ); + assert_eq!( + response.plugin.summary.keywords, + vec!["api-key".to_string(), "developer tools".to_string()] + ); assert_eq!(response.plugin.skills.len(), 1); assert_eq!( response.plugin.skills[0].name, @@ -750,6 +1368,23 @@ enabled = true "Summarize email threads" ); assert!(!response.plugin.skills[0].enabled); + assert_eq!( + response.plugin.hooks, + vec![ + codex_app_server_protocol::PluginHookSummary { + key: "demo-plugin@codex-curated:hooks/hooks.json:pre_tool_use:0:0".to_string(), + event_name: HookEventName::PreToolUse, + }, + codex_app_server_protocol::PluginHookSummary { + key: "demo-plugin@codex-curated:hooks/hooks.json:pre_tool_use:0:1".to_string(), + event_name: HookEventName::PreToolUse, + }, + codex_app_server_protocol::PluginHookSummary { + key: "demo-plugin@codex-curated:hooks/hooks.json:session_start:0:0".to_string(), + event_name: HookEventName::SessionStart, + }, + ] + ); assert_eq!(response.plugin.apps.len(), 1); assert_eq!(response.plugin.apps[0].id, "gmail"); assert_eq!(response.plugin.apps[0].name, "gmail"); @@ -1335,3 +1970,24 @@ fn write_plugin_source( )?; Ok(()) } + +fn write_plugin_share_local_path_mapping( + codex_home: &std::path::Path, + remote_plugin_id: &str, + plugin_path: &AbsolutePathBuf, +) -> std::io::Result<()> { + let mut local_plugin_paths_by_remote_plugin_id = serde_json::Map::new(); + local_plugin_paths_by_remote_plugin_id.insert( + remote_plugin_id.to_string(), + serde_json::to_value(plugin_path).map_err(std::io::Error::other)?, + ); + let contents = serde_json::to_string_pretty(&json!({ + "localPluginPathsByRemotePluginId": local_plugin_paths_by_remote_plugin_id, + })) + .map_err(std::io::Error::other)?; + std::fs::create_dir_all(codex_home.join(".tmp"))?; + std::fs::write( + codex_home.join(".tmp/plugin-share-local-paths-v1.json"), + format!("{contents}\n"), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/plugin_share.rs b/codex-rs/app-server/tests/suite/v2/plugin_share.rs index a44a64be7c..8614e83f8d 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -7,14 +7,21 @@ use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInterface; +use codex_app_server_protocol::PluginShareContext; use codex_app_server_protocol::PluginShareDeleteResponse; +use codex_app_server_protocol::PluginShareDiscoverability; use codex_app_server_protocol::PluginShareListItem; use codex_app_server_protocol::PluginShareListResponse; +use codex_app_server_protocol::PluginSharePrincipal; +use codex_app_server_protocol::PluginSharePrincipalRole; +use codex_app_server_protocol::PluginSharePrincipalType; use codex_app_server_protocol::PluginShareSaveResponse; +use codex_app_server_protocol::PluginShareUpdateTargetsResponse; use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::RequestId; @@ -155,8 +162,11 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { PluginShareListResponse { data: vec![PluginShareListItem { plugin: PluginSummary { - id: "plugins_123".to_string(), + id: "demo-plugin@workspace-shared-with-me-private".to_string(), + remote_plugin_id: Some("plugins_123".to_string()), + local_version: None, name: "demo-plugin".to_string(), + share_context: Some(expected_share_context("plugins_123")), source: PluginSource::Remote, installed: true, enabled: true, @@ -164,8 +174,8 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { auth_policy: PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(expected_plugin_interface()), + keywords: Vec::new(), }, - share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), local_plugin_path: Some(expected_plugin_path), }], } @@ -173,6 +183,332 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_share_save_forwards_access_policy() -> Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = TempDir::new()?; + let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + Mock::given(method("POST")) + .and(path("/backend-api/public/plugins/workspace/upload-url")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "file_id": "file_123", + "upload_url": format!("{}/upload/file_123", server.uri()), + "etag": "\"upload_etag_123\"", + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PUT")) + .and(path("/upload/file_123")) + .respond_with(ResponseTemplate::new(201).insert_header("etag", "\"blob_etag_123\"")) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/backend-api/public/plugins/workspace")) + .and(body_json(json!({ + "file_id": "file_123", + "etag": "\"upload_etag_123\"", + "discoverability": "UNLISTED", + "share_targets": [ + { + "principal_type": "user", + "principal_id": "user-1", + "role": "editor", + }, + { + "principal_type": "workspace", + "principal_id": "account-123", + "role": "reader", + }, + ], + }))) + .respond_with(ResponseTemplate::new(201).set_body_json(json!({ + "plugin_id": "plugins_123", + "share_url": "https://chatgpt.example/plugins/share/share-key-1", + }))) + .expect(1) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let expected_plugin_path = AbsolutePathBuf::try_from(plugin_path)?; + let request_id = mcp + .send_raw_request( + "plugin/share/save", + Some(json!({ + "pluginPath": expected_plugin_path, + "discoverability": "UNLISTED", + "shareTargets": [ + { + "principalType": "user", + "principalId": "user-1", + "role": "editor", + }, + ], + })), + ) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareSaveResponse = to_response(response)?; + + assert_eq!( + response, + PluginShareSaveResponse { + remote_plugin_id: "plugins_123".to_string(), + share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), + } + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_share_save_rejects_listed_discoverability() -> Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = TempDir::new()?; + let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request( + "plugin/share/save", + Some(json!({ + "pluginPath": AbsolutePathBuf::try_from(plugin_path)?, + "discoverability": "LISTED", + })), + ) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32600); + assert_eq!( + error.error.message, + "discoverability LISTED is not supported for plugin/share/save; use UNLISTED or PRIVATE" + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_share_save_rejects_when_plugin_sharing_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = TempDir::new()?; + let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#" +chatgpt_base_url = "{}/backend-api" + +[features] +plugins = true +remote_plugin = true +plugin_sharing = false +"#, + server.uri() + ), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request( + "plugin/share/save", + Some(json!({ + "pluginPath": AbsolutePathBuf::try_from(plugin_path)?, + })), + ) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32600); + assert_eq!(error.error.message, "plugin sharing is disabled"); + assert!( + server + .received_requests() + .await + .expect("wiremock should record requests") + .is_empty() + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_share_rejects_workspace_targets_from_client() -> Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = TempDir::new()?; + let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request( + "plugin/share/save", + Some(json!({ + "pluginPath": AbsolutePathBuf::try_from(plugin_path)?, + "discoverability": "UNLISTED", + "shareTargets": [ + { + "principalType": "workspace", + "principalId": "account-123", + "role": "reader", + }, + ], + })), + ) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32600); + assert_eq!( + error.error.message, + "shareTargets cannot include workspace principals; use discoverability UNLISTED for workspace link access" + ); + + let request_id = mcp + .send_raw_request( + "plugin/share/updateTargets", + Some(json!({ + "remotePluginId": "plugins_123", + "discoverability": "UNLISTED", + "shareTargets": [ + { + "principalType": "workspace", + "principalId": "account-123", + "role": "reader", + }, + ], + })), + ) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32600); + assert_eq!( + error.error.message, + "shareTargets cannot include workspace principals; use discoverability UNLISTED for workspace link access" + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_share_save_rejects_access_policy_for_existing_plugin() -> Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = TempDir::new()?; + let plugin_path = write_test_plugin(plugin_root.path(), "demo-plugin")?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request( + "plugin/share/save", + Some(json!({ + "pluginPath": AbsolutePathBuf::try_from(plugin_path)?, + "remotePluginId": "plugins_123", + "discoverability": "PRIVATE", + "shareTargets": [ + { + "principalType": "user", + "principalId": "user-1", + "role": "reader", + }, + ], + })), + ) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.error.code, -32600); + assert_eq!( + error.error.message, + "discoverability and shareTargets are only supported when creating a plugin share; use plugin/share/updateTargets to update share settings" + ); + Ok(()) +} + #[tokio::test] async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { let codex_home = TempDir::new()?; @@ -230,8 +566,11 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { PluginShareListResponse { data: vec![PluginShareListItem { plugin: PluginSummary { - id: "plugins_123".to_string(), + id: "demo-plugin@workspace-shared-with-me-private".to_string(), + remote_plugin_id: Some("plugins_123".to_string()), + local_version: None, name: "demo-plugin".to_string(), + share_context: Some(expected_share_context("plugins_123")), source: PluginSource::Remote, installed: true, enabled: true, @@ -239,8 +578,8 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { auth_policy: PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(expected_plugin_interface()), + keywords: Vec::new(), }, - share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), local_plugin_path: None, }], } @@ -248,6 +587,121 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_share_update_targets_updates_share_targets() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_config(codex_home.path(), &format!("{}/backend-api", server.uri()))?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + )?; + + Mock::given(method("PUT")) + .and(path("/backend-api/ps/plugins/plugins_123/shares")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .and(body_json(json!({ + "discoverability": "UNLISTED", + "targets": [ + { + "principal_type": "user", + "principal_id": "user-1", + "role": "editor", + }, + { + "principal_type": "workspace", + "principal_id": "account-123", + "role": "reader", + }, + ], + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "principals": [ + { + "principal_type": "user", + "principal_id": "owner-1", + "role": "owner", + "name": "Owner", + }, + { + "principal_type": "user", + "principal_id": "user-1", + "role": "editor", + "name": "Gavin", + }, + { + "principal_type": "workspace", + "principal_id": "account-123", + "role": "reader", + "name": "Workspace", + }, + ], + "discoverability": "UNLISTED", + }))) + .expect(1) + .mount(&server) + .await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let request_id = mcp + .send_raw_request( + "plugin/share/updateTargets", + Some(json!({ + "remotePluginId": "plugins_123", + "discoverability": "UNLISTED", + "shareTargets": [ + { + "principalType": "user", + "principalId": "user-1", + "role": "editor", + }, + ], + })), + ) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginShareUpdateTargetsResponse = to_response(response)?; + + assert_eq!( + response, + PluginShareUpdateTargetsResponse { + principals: vec![ + PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "owner-1".to_string(), + role: PluginSharePrincipalRole::Owner, + name: "Owner".to_string(), + }, + PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + role: PluginSharePrincipalRole::Editor, + name: "Gavin".to_string(), + }, + PluginSharePrincipal { + principal_type: PluginSharePrincipalType::Workspace, + principal_id: "account-123".to_string(), + role: PluginSharePrincipalRole::Reader, + name: "Workspace".to_string(), + }, + ], + discoverability: codex_app_server_protocol::PluginShareDiscoverability::Unlisted, + } + ); + Ok(()) +} + #[tokio::test] async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { let codex_home = TempDir::new()?; @@ -333,8 +787,11 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { PluginShareListResponse { data: vec![PluginShareListItem { plugin: PluginSummary { - id: "plugins_123".to_string(), + id: "demo-plugin@workspace-shared-with-me-private".to_string(), + remote_plugin_id: Some("plugins_123".to_string()), + local_version: None, name: "demo-plugin".to_string(), + share_context: Some(expected_share_context("plugins_123")), source: PluginSource::Remote, installed: true, enabled: true, @@ -342,8 +799,8 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { auth_policy: PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(expected_plugin_interface()), + keywords: Vec::new(), }, - share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), local_plugin_path: None, }], } @@ -371,10 +828,26 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value { "id": plugin_id, "name": "demo-plugin", "scope": "WORKSPACE", + "discoverability": "PRIVATE", "share_url": "https://chatgpt.example/plugins/share/share-key-1", + "share_principals": [ + { + "principal_type": "user", + "principal_id": "user-owner__account-123", + "role": "owner", + "name": "Owner" + }, + { + "principal_type": "user", + "principal_id": "user-reader__account-123", + "role": "reader", + "name": "Reader" + } + ], "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "release": { + "version": "0.1.0", "display_name": "Demo Plugin", "description": "Demo plugin description", "interface": { @@ -424,6 +897,31 @@ fn expected_plugin_interface() -> PluginInterface { } } +fn expected_share_context(plugin_id: &str) -> PluginShareContext { + PluginShareContext { + remote_plugin_id: plugin_id.to_string(), + remote_version: Some("0.1.0".to_string()), + discoverability: Some(PluginShareDiscoverability::Private), + share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()), + creator_account_user_id: None, + creator_name: None, + share_principals: Some(vec![ + PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-owner__account-123".to_string(), + role: PluginSharePrincipalRole::Owner, + name: "Owner".to_string(), + }, + PluginSharePrincipal { + principal_type: PluginSharePrincipalType::User, + principal_id: "user-reader__account-123".to_string(), + role: PluginSharePrincipalRole::Reader, + name: "Reader".to_string(), + }, + ]), + } +} + fn write_test_plugin(root: &Path, plugin_name: &str) -> std::io::Result { let plugin_path = root.join(plugin_name); write_file( diff --git a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs index 26d1e2f884..8fdf149a01 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -153,8 +153,14 @@ async fn plugin_uninstall_tracks_analytics_event() -> Result<()> { } #[tokio::test] -async fn plugin_uninstall_rejects_remote_plugin_when_remote_plugin_is_disabled() -> Result<()> { +async fn plugin_uninstall_rejects_remote_plugin_when_plugins_are_disabled() -> Result<()> { let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = false +"#, + )?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -285,7 +291,7 @@ async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()> let workspace_cache_root = codex_home .path() - .join("plugins/cache/chatgpt-workspace/linear"); + .join("plugins/cache/workspace-directory/linear"); std::fs::create_dir_all(workspace_cache_root.join("1.0.0/.codex-plugin"))?; std::fs::write( workspace_cache_root.join("1.0.0/.codex-plugin/plugin.json"), @@ -363,7 +369,7 @@ async fn plugin_uninstall_accepts_workspace_remote_plugin_id_shape() -> Result<( let remote_plugin_cache_root = codex_home .path() - .join("plugins/cache/chatgpt-workspace/skill-improver"); + .join("plugins/cache/workspace-directory/skill-improver"); std::fs::create_dir_all(remote_plugin_cache_root.join("1.0.0/.codex-plugin"))?; std::fs::write( remote_plugin_cache_root.join("1.0.0/.codex-plugin/plugin.json"), @@ -454,7 +460,7 @@ async fn plugin_uninstall_rejects_before_post_when_remote_detail_fetch_fails() - } #[tokio::test] -async fn plugin_uninstall_rejects_invalid_plugin_id_before_remote_path() -> Result<()> { +async fn plugin_uninstall_rejects_remote_plugin_id_with_spaces_before_network_call() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; write_remote_plugin_catalog_config( @@ -477,7 +483,7 @@ async fn plugin_uninstall_rejects_invalid_plugin_id_before_remote_path() -> Resu .await??; assert_eq!(err.error.code, -32600); - assert!(err.error.message.contains("invalid plugin id")); + assert!(err.error.message.contains("invalid remote plugin id")); wait_for_remote_plugin_request_count( &server, "POST", @@ -512,7 +518,7 @@ async fn plugin_uninstall_rejects_invalid_remote_plugin_id_before_network_call() .await??; assert_eq!(err.error.code, -32600); - assert!(err.error.message.contains("invalid plugin id")); + assert!(err.error.message.contains("invalid remote plugin id")); wait_for_remote_plugin_request_count( &server, "POST", @@ -546,7 +552,7 @@ async fn plugin_uninstall_rejects_empty_remote_plugin_id() -> Result<()> { .await??; assert_eq!(err.error.code, -32600); - assert!(err.error.message.contains("invalid plugin id")); + assert!(err.error.message.contains("invalid remote plugin id")); Ok(()) } @@ -611,11 +617,17 @@ async fn mount_remote_plugin_detail_with_name( release_version: &str, scope: &str, ) { + let discoverability = if scope == "WORKSPACE" { + r#" + "discoverability": "LISTED","# + } else { + "" + }; let detail_body = format!( r#"{{ "id": "{remote_plugin_id}", "name": "{plugin_name}", - "scope": "{scope}", + "scope": "{scope}",{discoverability} "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "release": {{ diff --git a/codex-rs/app-server/tests/suite/v2/process_exec.rs b/codex-rs/app-server/tests/suite/v2/process_exec.rs new file mode 100644 index 0000000000..5dd3e84b4c --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/process_exec.rs @@ -0,0 +1,250 @@ +use anyhow::Context; +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_mock_responses_server_sequence_unchecked; +use codex_app_server_protocol::ProcessExitedNotification; +use codex_app_server_protocol::ProcessKillParams; +use codex_app_server_protocol::ProcessSpawnParams; +use codex_app_server_protocol::RequestId; +use codex_utils_absolute_path::AbsolutePathBuf; +use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::Duration; +use tokio::time::sleep; +use tokio::time::timeout; +use wiremock::MockServer; + +use super::connection_handling_websocket::DEFAULT_READ_TIMEOUT; +use super::connection_handling_websocket::create_config_toml; + +#[tokio::test] +async fn process_spawn_returns_before_exit_and_emits_exit_notification() -> Result<()> { + let codex_home = TempDir::new()?; + let (_server, mut mcp) = initialized_mcp(codex_home.path()).await?; + + let process_handle = "one-shot-1".to_string(); + let probe_file = codex_home.path().join("process-created"); + let release_file = codex_home.path().join("process-release"); + // Use a probe/release handshake instead of asserting on wall-clock timing: + // the child proves it started by writing the probe file, then waits for the + // test to create the release file before it can emit output and exit. + let command = if cfg!(windows) { + vec![ + "powershell.exe".to_string(), + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + concat!( + "[IO.File]::WriteAllText($env:CODEX_PROCESS_EXEC_PROBE_FILE, 'process'); ", + "while (!(Test-Path -LiteralPath $env:CODEX_PROCESS_EXEC_RELEASE_FILE)) { ", + "Start-Sleep -Milliseconds 20 ", + "}; ", + "[Console]::Out.Write('process-out'); ", + "[Console]::Error.Write('process-err')", + ) + .to_string(), + ] + } else { + vec![ + "sh".to_string(), + "-c".to_string(), + concat!( + "printf process > \"$CODEX_PROCESS_EXEC_PROBE_FILE\"; ", + "while [ ! -e \"$CODEX_PROCESS_EXEC_RELEASE_FILE\" ]; do sleep 0.05; done; ", + "printf process-out; ", + "printf process-err >&2", + ) + .to_string(), + ] + }; + let env = HashMap::from([ + ( + "CODEX_PROCESS_EXEC_PROBE_FILE".to_string(), + Some(probe_file.display().to_string()), + ), + ( + "CODEX_PROCESS_EXEC_RELEASE_FILE".to_string(), + Some(release_file.display().to_string()), + ), + ]); + let spawn_request_id = mcp + .send_process_spawn_request(ProcessSpawnParams { + env: Some(env), + output_bytes_cap: Some(None), + timeout_ms: Some(None), + ..process_spawn_params(process_handle.clone(), codex_home.path(), command)? + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(spawn_request_id)) + .await?; + assert_eq!(response.result, serde_json::json!({})); + + wait_for_file(&probe_file).await?; + assert_eq!(std::fs::read_to_string(&probe_file)?, "process"); + std::fs::write(&release_file, "release")?; + + let exited = read_process_exited(&mut mcp).await?; + assert_eq!( + exited, + ProcessExitedNotification { + process_handle, + exit_code: 0, + stdout: "process-out".to_string(), + stdout_cap_reached: false, + stderr: "process-err".to_string(), + stderr_cap_reached: false, + } + ); + Ok(()) +} + +#[tokio::test] +async fn process_spawn_reports_buffered_output_cap_reached() -> Result<()> { + let codex_home = TempDir::new()?; + let (_server, mut mcp) = initialized_mcp(codex_home.path()).await?; + + let process_handle = "capped-one-shot-1".to_string(); + let command = if cfg!(windows) { + vec![ + "powershell.exe".to_string(), + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + "[Console]::Out.Write('abcde'); [Console]::Error.Write('12345')".to_string(), + ] + } else { + vec![ + "sh".to_string(), + "-lc".to_string(), + "printf abcde; printf 12345 >&2".to_string(), + ] + }; + let spawn_request_id = mcp + .send_process_spawn_request(ProcessSpawnParams { + output_bytes_cap: Some(Some(3)), + ..process_spawn_params(process_handle.clone(), codex_home.path(), command)? + }) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(spawn_request_id)) + .await?; + assert_eq!(response.result, serde_json::json!({})); + + let exited = read_process_exited(&mut mcp).await?; + assert_eq!( + exited, + ProcessExitedNotification { + process_handle, + exit_code: 0, + stdout: "abc".to_string(), + stdout_cap_reached: true, + stderr: "123".to_string(), + stderr_cap_reached: true, + } + ); + + Ok(()) +} + +#[tokio::test] +async fn process_kill_terminates_running_process() -> Result<()> { + let codex_home = TempDir::new()?; + let (_server, mut mcp) = initialized_mcp(codex_home.path()).await?; + + let process_handle = "sleep-process-1".to_string(); + let command = if cfg!(windows) { + vec![ + "powershell.exe".to_string(), + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + "-Command".to_string(), + "Start-Sleep -Seconds 30".to_string(), + ] + } else { + vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()] + }; + let spawn_request_id = mcp + .send_process_spawn_request(process_spawn_params( + process_handle.clone(), + codex_home.path(), + command, + )?) + .await?; + + let response = mcp + .read_stream_until_response_message(RequestId::Integer(spawn_request_id)) + .await?; + assert_eq!(response.result, serde_json::json!({})); + + let kill_request_id = mcp + .send_process_kill_request(ProcessKillParams { + process_handle: process_handle.clone(), + }) + .await?; + let kill_response = mcp + .read_stream_until_response_message(RequestId::Integer(kill_request_id)) + .await?; + assert_eq!(kill_response.result, serde_json::json!({})); + + let exited = read_process_exited(&mut mcp).await?; + assert_eq!(exited.process_handle, process_handle); + assert_ne!(exited.exit_code, 0); + assert_eq!(exited.stdout, ""); + assert!(!exited.stdout_cap_reached); + assert_eq!(exited.stderr, ""); + assert!(!exited.stderr_cap_reached); + + Ok(()) +} + +async fn initialized_mcp(codex_home: &Path) -> Result<(MockServer, McpProcess)> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + create_config_toml(codex_home, &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + Ok((server, mcp)) +} + +fn process_spawn_params( + process_handle: String, + cwd: &Path, + command: Vec, +) -> Result { + Ok(ProcessSpawnParams { + command, + process_handle, + cwd: AbsolutePathBuf::try_from(cwd)?, + tty: false, + stream_stdin: false, + stream_stdout_stderr: false, + output_bytes_cap: None, + timeout_ms: None, + env: None, + size: None, + }) +} + +async fn read_process_exited(mcp: &mut McpProcess) -> Result { + let notification = mcp + .read_stream_until_notification_message("process/exited") + .await?; + let params = notification + .params + .context("process/exited notification should include params")?; + serde_json::from_value(params).context("deserialize process/exited notification") +} + +async fn wait_for_file(path: &Path) -> Result<()> { + timeout(DEFAULT_READ_TIMEOUT, async { + while !path.exists() { + sleep(Duration::from_millis(20)).await; + } + }) + .await + .context("timed out waiting for process probe file") +} diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index 4ae9187ea9..975819dc7f 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -1225,14 +1225,14 @@ async fn webrtc_v1_start_posts_offer_returns_sdp_and_joins_sideband() -> Result< "v=offer\r\n", v1_session_create_json(), )?; + + let session_update = harness.sideband_outbound_request(/*request_index*/ 0).await; + assert_v1_session_update(&session_update)?; assert_eq!( harness.realtime_server.single_handshake().uri(), "/v1/realtime?intent=quicksilver&call_id=rtc_e2e" ); - let session_update = harness.sideband_outbound_request(/*request_index*/ 0).await; - assert_v1_session_update(&session_update)?; - let closed = timeout( Duration::from_millis(100), harness diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index a76caefebb..e5c0b2c53f 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -3,9 +3,8 @@ //! //! The app-server startup path should honor `experimental_thread_store` //! by routing all thread persistence through the configured store. This suite uses -//! the thread-store crate's test-only in-memory store, which exercises the same -//! config-driven selection path as a remote store without requiring the real gRPC -//! service. +//! the thread-store crate's test-only in-memory store to exercise the non-local +//! config-driven selection path without touching local rollout or sqlite storage. //! //! The important failure mode is accidentally materializing local persistence //! while a non-local store is configured. After `thread/start` and a simple turn, @@ -80,6 +79,7 @@ async fn thread_start_with_non_local_thread_store_does_not_create_local_persiste thread_config_loader: Arc::new(NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, + state_db: None, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source: SessionSource::Cli, diff --git a/codex-rs/app-server/tests/suite/v2/review.rs b/codex-rs/app-server/tests/suite/v2/review.rs index d56b9318e3..bf0271f821 100644 --- a/codex-rs/app-server/tests/suite/v2/review.rs +++ b/codex-rs/app-server/tests/suite/v2/review.rs @@ -22,6 +22,7 @@ use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; use codex_app_server_protocol::ThreadStatusChangedNotification; +use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput as V2UserInput; @@ -85,6 +86,17 @@ async fn review_start_runs_review_turn_and_emits_code_review_item() -> Result<() assert_eq!(review_thread_id, thread_id.clone()); let turn_id = turn.id.clone(); assert_eq!(turn.status, TurnStatus::InProgress); + assert_eq!(turn.items_view, TurnItemsView::NotLoaded); + assert_eq!( + turn.items, + vec![ThreadItem::UserMessage { + id: turn_id.clone(), + content: vec![V2UserInput::Text { + text: "commit 1234567: Tidy UI colors".to_string(), + text_elements: Vec::new(), + }], + }] + ); // Confirm we see the EnteredReviewMode marker on the main thread. let mut saw_entered_review_mode = false; @@ -182,6 +194,17 @@ async fn review_start_exec_approval_item_id_matches_command_execution_item() -> .await??; let ReviewStartResponse { turn, .. } = to_response::(review_resp)?; let turn_id = turn.id.clone(); + assert_eq!(turn.items_view, TurnItemsView::NotLoaded); + assert_eq!( + turn.items, + vec![ThreadItem::UserMessage { + id: turn_id.clone(), + content: vec![V2UserInput::Text { + text: "commit 1234567: Check review approvals".to_string(), + text_elements: Vec::new(), + }], + }] + ); let server_req = timeout( DEFAULT_READ_TIMEOUT, @@ -300,6 +323,17 @@ async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<( } = to_response::(review_resp)?; assert_eq!(turn.status, TurnStatus::InProgress); + assert_eq!(turn.items_view, TurnItemsView::NotLoaded); + assert_eq!( + turn.items, + vec![ThreadItem::UserMessage { + id: turn.id.clone(), + content: vec![V2UserInput::Text { + text: "detached review".to_string(), + text_elements: Vec::new(), + }], + }] + ); assert_ne!( review_thread_id, thread_id, "detached review should run on a different thread" @@ -329,6 +363,7 @@ async fn review_start_with_detached_delivery_returns_new_thread_id() -> Result<( let started: ThreadStartedNotification = serde_json::from_value(notification.params.expect("params must be present"))?; assert_eq!(started.thread.id, review_thread_id); + assert_eq!(started.thread.session_id, review_thread_id); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index b95adb9044..416b2515ad 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -4,14 +4,15 @@ use anyhow::Context; use anyhow::Result; use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; +use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SkillsChangedNotification; -use codex_app_server_protocol::SkillsListExtraRootsForCwd; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadStartParams; @@ -133,44 +134,6 @@ fn write_cached_remote_plugin_with_skill( Ok(skill_path) } -#[tokio::test] -async fn skills_list_includes_skills_from_per_cwd_extra_user_roots() -> Result<()> { - let codex_home = TempDir::new()?; - let cwd = TempDir::new()?; - let extra_root = TempDir::new()?; - write_skill(&extra_root, "extra-skill")?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - - let request_id = mcp - .send_skills_list_request(SkillsListParams { - cwds: vec![cwd.path().to_path_buf()], - force_reload: true, - per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { - cwd: cwd.path().to_path_buf(), - extra_user_roots: vec![extra_root.path().to_path_buf()], - }]), - }) - .await?; - - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let SkillsListResponse { data } = to_response(response)?; - assert_eq!(data.len(), 1); - assert_eq!(data[0].cwd.as_path(), cwd.path()); - assert!( - data[0] - .skills - .iter() - .any(|skill| skill.name == "extra-skill") - ); - Ok(()) -} - #[tokio::test] async fn skills_list_loads_remote_installed_plugin_skills_from_cache() -> Result<()> { let codex_home = TempDir::new()?; @@ -266,7 +229,6 @@ async fn skills_list_loads_remote_installed_plugin_skills_from_cache() -> Result .send_skills_list_request(SkillsListParams { cwds: vec![cwd.path().to_path_buf()], force_reload: true, - per_cwd_extra_user_roots: None, }) .await?; let stale_skills_list_response: JSONRPCResponse = timeout( @@ -299,7 +261,10 @@ async fn skills_list_loads_remote_installed_plugin_skills_from_cache() -> Result } let plugin_list_request_id = mcp - .send_plugin_list_request(PluginListParams { cwds: None }) + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: None, + }) .await?; let plugin_list_response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, @@ -314,7 +279,6 @@ async fn skills_list_loads_remote_installed_plugin_skills_from_cache() -> Result .send_skills_list_request(SkillsListParams { cwds: vec![cwd.path().to_path_buf()], force_reload: false, - per_cwd_extra_user_roots: None, }) .await?; let skills_list_response: JSONRPCResponse = timeout( @@ -389,7 +353,6 @@ async fn skills_list_excludes_plugin_skills_when_workspace_codex_plugins_disable .send_skills_list_request(SkillsListParams { cwds: vec![repo_root.path().to_path_buf()], force_reload: true, - per_cwd_extra_user_roots: None, }) .await?; @@ -421,9 +384,13 @@ async fn skills_list_excludes_plugin_skills_when_workspace_codex_plugins_disable async fn skills_list_skips_cwd_roots_when_environment_disabled() -> Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; - let extra_root = TempDir::new()?; write_skill(&codex_home, "home-skill")?; - write_skill(&extra_root, "extra-skill")?; + let repo_skill_dir = cwd.path().join(".codex/skills/repo-skill"); + std::fs::create_dir_all(&repo_skill_dir)?; + std::fs::write( + repo_skill_dir.join("SKILL.md"), + "---\nname: repo-skill\ndescription: from repo root\n---\n\n# Body\n", + )?; let mut mcp = McpProcess::new_with_env( codex_home.path(), @@ -436,10 +403,6 @@ async fn skills_list_skips_cwd_roots_when_environment_disabled() -> Result<()> { .send_skills_list_request(SkillsListParams { cwds: vec![cwd.path().to_path_buf()], force_reload: true, - per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { - cwd: cwd.path().to_path_buf(), - extra_user_roots: vec![extra_root.path().to_path_buf()], - }]), }) .await?; @@ -462,41 +425,7 @@ async fn skills_list_skips_cwd_roots_when_environment_disabled() -> Result<()> { data[0] .skills .iter() - .all(|skill| skill.name != "extra-skill") - ); - Ok(()) -} - -#[tokio::test] -async fn skills_list_rejects_relative_extra_user_roots() -> Result<()> { - let codex_home = TempDir::new()?; - let cwd = TempDir::new()?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - - let request_id = mcp - .send_skills_list_request(SkillsListParams { - cwds: vec![cwd.path().to_path_buf()], - force_reload: true, - per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { - cwd: cwd.path().to_path_buf(), - extra_user_roots: vec![std::path::PathBuf::from("relative/skills")], - }]), - }) - .await?; - - let err = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(request_id)), - ) - .await??; - assert!( - err.error - .message - .contains("perCwdExtraUserRoots extraUserRoots paths must be absolute"), - "unexpected error: {}", - err.error.message + .all(|skill| skill.name != "repo-skill") ); Ok(()) } @@ -514,7 +443,6 @@ async fn skills_list_accepts_relative_cwds() -> Result<()> { .send_skills_list_request(SkillsListParams { cwds: vec![relative_cwd.clone()], force_reload: true, - per_cwd_extra_user_roots: None, }) .await?; @@ -531,24 +459,21 @@ async fn skills_list_accepts_relative_cwds() -> Result<()> { } #[tokio::test] -async fn skills_list_ignores_per_cwd_extra_roots_for_unknown_cwd() -> Result<()> { +async fn skills_list_preserves_requested_cwd_order() -> Result<()> { let codex_home = TempDir::new()?; - let requested_cwd = TempDir::new()?; - let unknown_cwd = TempDir::new()?; - let extra_root = TempDir::new()?; - write_skill(&extra_root, "ignored-extra-skill")?; + let first_cwd = TempDir::new()?; + let second_cwd = TempDir::new()?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_skills_list_request(SkillsListParams { - cwds: vec![requested_cwd.path().to_path_buf()], + cwds: vec![ + first_cwd.path().to_path_buf(), + second_cwd.path().to_path_buf(), + ], force_reload: true, - per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { - cwd: unknown_cwd.path().to_path_buf(), - extra_user_roots: vec![extra_root.path().to_path_buf()], - }]), }) .await?; @@ -558,13 +483,14 @@ async fn skills_list_ignores_per_cwd_extra_roots_for_unknown_cwd() -> Result<()> ) .await??; let SkillsListResponse { data } = to_response(response)?; - assert_eq!(data.len(), 1); - assert_eq!(data[0].cwd.as_path(), requested_cwd.path()); - assert!( - data[0] - .skills - .iter() - .all(|skill| skill.name != "ignored-extra-skill") + assert_eq!( + data.iter() + .map(|entry| entry.cwd.clone()) + .collect::>(), + vec![ + first_cwd.path().to_path_buf(), + second_cwd.path().to_path_buf(), + ] ); Ok(()) } @@ -573,18 +499,15 @@ async fn skills_list_ignores_per_cwd_extra_roots_for_unknown_cwd() -> Result<()> async fn skills_list_uses_cached_result_until_force_reload() -> Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; - let extra_root = TempDir::new()?; - write_skill(&extra_root, "late-extra-skill")?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - // Seed the cwd cache first without extra roots. + // Seed the cwd cache before the cwd-local skill exists. let first_request_id = mcp .send_skills_list_request(SkillsListParams { cwds: vec![cwd.path().to_path_buf()], force_reload: false, - per_cwd_extra_user_roots: None, }) .await?; let first_response: JSONRPCResponse = timeout( @@ -601,14 +524,17 @@ async fn skills_list_uses_cached_result_until_force_reload() -> Result<()> { .all(|skill| skill.name != "late-extra-skill") ); + let skill_dir = cwd.path().join(".codex/skills/late-extra-skill"); + std::fs::create_dir_all(&skill_dir)?; + std::fs::write( + skill_dir.join("SKILL.md"), + "---\nname: late-extra-skill\ndescription: late skill\n---\n\n# Body\n", + )?; + let second_request_id = mcp .send_skills_list_request(SkillsListParams { cwds: vec![cwd.path().to_path_buf()], force_reload: false, - per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { - cwd: cwd.path().to_path_buf(), - extra_user_roots: vec![extra_root.path().to_path_buf()], - }]), }) .await?; let second_response: JSONRPCResponse = timeout( @@ -629,10 +555,6 @@ async fn skills_list_uses_cached_result_until_force_reload() -> Result<()> { .send_skills_list_request(SkillsListParams { cwds: vec![cwd.path().to_path_buf()], force_reload: true, - per_cwd_extra_user_roots: Some(vec![SkillsListExtraRootsForCwd { - cwd: cwd.path().to_path_buf(), - extra_user_roots: vec![extra_root.path().to_path_buf()], - }]), }) .await?; let third_response: JSONRPCResponse = timeout( @@ -653,11 +575,39 @@ async fn skills_list_uses_cached_result_until_force_reload() -> Result<()> { #[tokio::test] async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + &server.uri(), + &server.uri(), + )?; write_skill(&codex_home, "demo")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let mut mcp = + McpProcess::new_with_env(codex_home.path(), &[(CODEX_EXEC_SERVER_URL_ENV_VAR, None)]) + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + let initial_skills_request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![codex_home.path().to_path_buf()], + force_reload: true, + }) + .await?; + let initial_skills_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(initial_skills_request_id)), + ) + .await??; + let SkillsListResponse { data } = to_response(initial_skills_response)?; + assert_eq!(data.len(), 1); + assert!( + data[0] + .skills + .iter() + .any(|skill| { skill.name == "demo" && skill.description == "demo description" }) + ); + let thread_start_request_id = mcp .send_thread_start_request(ThreadStartParams { model: None, @@ -675,6 +625,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( personality: None, ephemeral: None, session_start_source: None, + thread_source: None, dynamic_tools: None, environments: None, mock_experimental_field: None, @@ -709,5 +660,24 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( let notification: SkillsChangedNotification = serde_json::from_value(params)?; assert_eq!(notification, SkillsChangedNotification {}); + let updated_skills_request_id = mcp + .send_skills_list_request(SkillsListParams { + cwds: vec![codex_home.path().to_path_buf()], + force_reload: false, + }) + .await?; + let updated_skills_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(updated_skills_request_id)), + ) + .await??; + let SkillsListResponse { data } = to_response(updated_skills_response)?; + assert_eq!(data.len(), 1); + assert!( + data[0] + .skills + .iter() + .any(|skill| skill.name == "demo" && skill.description == "updated") + ); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_archive.rs b/codex-rs/app-server/tests/suite/v2/thread_archive.rs index 7d884c9a7d..b441a23cb6 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_archive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_archive.rs @@ -63,7 +63,7 @@ async fn thread_archive_requires_materialized_rollout() -> Result<()> { rollout_path.display() ); assert!( - find_thread_path_by_id_str(codex_home.path(), &thread.id) + find_thread_path_by_id_str(codex_home.path(), &thread.id, /*state_db_ctx*/ None) .await? .is_none(), "thread id should not be discoverable before rollout materialization" @@ -118,9 +118,10 @@ async fn thread_archive_requires_materialized_rollout() -> Result<()> { rollout_path.display() ); - let discovered_path = find_thread_path_by_id_str(codex_home.path(), &thread.id) - .await? - .expect("expected rollout path for thread id to exist after materialization"); + let discovered_path = + find_thread_path_by_id_str(codex_home.path(), &thread.id, /*state_db_ctx*/ None) + .await? + .expect("expected rollout path for thread id to exist after materialization"); assert_paths_match_on_disk(&discovered_path, &rollout_path)?; let archive_id = mcp @@ -252,15 +253,23 @@ async fn thread_archive_archives_spawned_descendants() -> Result<()> { for thread_id in [parent_thread_id, child_thread_id, grandchild_thread_id] { assert!( - find_thread_path_by_id_str(codex_home.path(), &thread_id.to_string()) - .await? - .is_none(), + find_thread_path_by_id_str( + codex_home.path(), + &thread_id.to_string(), + /*state_db_ctx*/ None, + ) + .await? + .is_none(), "expected active rollout for {thread_id} to be archived" ); assert!( - find_archived_thread_path_by_id_str(codex_home.path(), &thread_id.to_string()) - .await? - .is_some(), + find_archived_thread_path_by_id_str( + codex_home.path(), + &thread_id.to_string(), + /*state_db_ctx*/ None, + ) + .await? + .is_some(), "expected archived rollout for {thread_id} to exist" ); } @@ -322,9 +331,10 @@ async fn thread_archive_succeeds_when_descendant_archive_fails() -> Result<()> { ) .await?; - let child_rollout_path = find_thread_path_by_id_str(codex_home.path(), &child_id) - .await? - .expect("child rollout path"); + let child_rollout_path = + find_thread_path_by_id_str(codex_home.path(), &child_id, /*state_db_ctx*/ None) + .await? + .expect("child rollout path"); let archived_child_path = codex_home .path() .join(ARCHIVED_SESSIONS_SUBDIR) @@ -381,15 +391,23 @@ async fn thread_archive_succeeds_when_descendant_archive_fails() -> Result<()> { ); for thread_id in [parent_thread_id, grandchild_thread_id] { assert!( - find_thread_path_by_id_str(codex_home.path(), &thread_id.to_string()) - .await? - .is_none(), + find_thread_path_by_id_str( + codex_home.path(), + &thread_id.to_string(), + /*state_db_ctx*/ None, + ) + .await? + .is_none(), "expected active rollout for {thread_id} to be archived" ); assert!( - find_archived_thread_path_by_id_str(codex_home.path(), &thread_id.to_string()) - .await? - .is_some(), + find_archived_thread_path_by_id_str( + codex_home.path(), + &thread_id.to_string(), + /*state_db_ctx*/ None, + ) + .await? + .is_some(), "expected archived rollout for {thread_id} to exist" ); } @@ -455,15 +473,19 @@ async fn thread_archive_succeeds_when_spawned_descendant_is_missing() -> Result< assert_eq!(archived_notification.thread_id, parent_id); assert!( - find_thread_path_by_id_str(codex_home.path(), &parent_id) + find_thread_path_by_id_str(codex_home.path(), &parent_id, /*state_db_ctx*/ None) .await? .is_none(), "parent should be archived even when a descendant is missing" ); assert!( - find_archived_thread_path_by_id_str(codex_home.path(), &parent_id) - .await? - .is_some(), + find_archived_thread_path_by_id_str( + codex_home.path(), + &parent_id, + /*state_db_ctx*/ None, + ) + .await? + .is_some(), "parent should be moved into archived sessions" ); 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 fd773f2e30..3eb262bd2b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -17,6 +17,7 @@ use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; @@ -32,7 +33,6 @@ use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; use std::path::Path; -use std::path::PathBuf; use tempfile::TempDir; use tokio::time::timeout; use wiremock::Mock; @@ -50,7 +50,6 @@ use super::analytics::wait_for_analytics_payload; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); -const INTERNAL_ERROR_CODE: i64 = -32603; #[tokio::test] async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { @@ -90,6 +89,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { let fork_id = mcp .send_thread_fork_request(ThreadForkParams { thread_id: conversation_id.clone(), + thread_source: Some(ThreadSource::User), ..Default::default() }) .await?; @@ -106,11 +106,21 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { .get("thread") .and_then(Value::as_object) .expect("thread/fork result.thread must be an object"); + assert_eq!( + thread_json.get("sessionId").and_then(Value::as_str), + Some(thread.session_id.as_str()), + "forked threads should serialize `sessionId` on the thread object" + ); assert_eq!( thread_json.get("name"), Some(&Value::Null), "forked threads do not inherit a name; expected `name: null`" ); + assert_eq!( + fork_result.get("sessionId"), + None, + "thread/fork should not serialize a top-level `sessionId`" + ); let after_contents = std::fs::read_to_string(&original_path)?; assert_eq!( @@ -119,6 +129,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { ); assert_ne!(thread.id, conversation_id); + assert_eq!(thread.session_id, thread.id); assert_eq!(thread.forked_from_id, Some(conversation_id.clone())); assert_eq!(thread.preview, preview); assert_eq!(thread.model_provider, "mock_provider"); @@ -128,6 +139,7 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { assert_ne!(thread_path.as_path(), original_path); assert!(thread.cwd.as_path().is_absolute()); assert_eq!(thread.source, SessionSource::VsCode); + assert_eq!(thread.thread_source, Some(ThreadSource::User)); assert_eq!(thread.name, None); assert_eq!( @@ -188,6 +200,13 @@ async fn thread_fork_creates_new_thread_and_emits_started() -> Result<()> { Some(&json!([])), "thread/started must not emit copied fork turns" ); + assert_eq!( + started_thread_json + .get("threadSource") + .and_then(Value::as_str), + Some("user"), + "thread/started should preserve the caller-supplied fork origin" + ); let started: ThreadStartedNotification = serde_json::from_value(notif.params.expect("params must be present"))?; let mut expected_started_thread = thread; @@ -248,37 +267,6 @@ async fn thread_fork_can_load_source_by_path() -> Result<()> { Ok(()) } -#[tokio::test] -async fn thread_fork_by_path_uses_remote_thread_store_error() -> Result<()> { - let server = create_mock_responses_server_repeating_assistant("Done").await; - let codex_home = TempDir::new()?; - create_config_toml_with_remote_thread_store(codex_home.path(), &server.uri())?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let fork_id = mcp - .send_thread_fork_request(ThreadForkParams { - thread_id: "not-a-valid-thread-id".to_string(), - path: Some(PathBuf::from("sessions/2025/01/05/rollout.jsonl")), - ..Default::default() - }) - .await?; - let fork_err: JSONRPCError = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(fork_id)), - ) - .await??; - - assert_eq!(fork_err.error.code, INTERNAL_ERROR_CODE); - assert_eq!( - fork_err.error.message, - "failed to read thread: thread-store internal error: remote thread store does not support read_thread_by_rollout_path" - ); - - Ok(()) -} - #[tokio::test] async fn thread_fork_emits_restored_token_usage_before_next_turn() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -299,6 +287,7 @@ async fn thread_fork_emits_restored_token_usage_before_next_turn() -> Result<()> let fork_id = mcp .send_thread_fork_request(ThreadForkParams { thread_id: conversation_id, + thread_source: Some(ThreadSource::User), ..Default::default() }) .await?; @@ -403,6 +392,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { let fork_id = mcp .send_thread_fork_request(ThreadForkParams { thread_id: conversation_id, + thread_source: Some(ThreadSource::User), ..Default::default() }) .await?; @@ -415,7 +405,7 @@ async fn thread_fork_tracks_thread_initialized_analytics() -> Result<()> { 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"); + assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "forked", "user"); Ok(()) } @@ -756,33 +746,6 @@ stream_max_retries = 0 ) } -fn create_config_toml_with_remote_thread_store( - codex_home: &Path, - server_uri: &str, -) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "mock-model" -approval_policy = "never" -sandbox_mode = "read-only" -experimental_thread_store_endpoint = "http://127.0.0.1:1" - -model_provider = "mock_provider" - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} - fn create_config_toml_with_chatgpt_base_url( codex_home: &Path, server_uri: &str, diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 615692d70d..80254d8f47 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -614,6 +614,7 @@ sqlite = true generate_memories: false, }; let repaired_page = codex_core::RolloutRecorder::list_threads( + Some(state_db.clone()), &rollout_config, /*page_size*/ 10, /*cursor*/ None, @@ -878,6 +879,7 @@ async fn thread_list_filters_by_source_kind_subagent_thread_spawn() -> Result<() assert_eq!(ids, vec![subagent_id.as_str()]); assert_ne!(cli_id, subagent_id); assert!(matches!(data[0].source, SessionSource::SubAgent(_))); + assert_eq!(data[0].session_id, subagent_id); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs index d06b22edc4..c78e9b8152 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_metadata_update.rs @@ -33,6 +33,7 @@ use tempfile::TempDir; use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const INVALID_REQUEST_ERROR_CODE: i64 = -32600; #[tokio::test] async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread() -> Result<()> { @@ -76,6 +77,7 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread() to_response::(update_resp)?; assert_eq!(updated.id, thread.id); + assert_eq!(updated.session_id, thread.session_id); assert_eq!( updated.git_info, Some(GitInfo { @@ -89,6 +91,10 @@ async fn thread_metadata_update_patches_git_branch_and_returns_updated_thread() .get("thread") .and_then(Value::as_object) .expect("thread/metadata/update result.thread must be an object"); + assert_eq!( + updated_thread_json.get("sessionId").and_then(Value::as_str), + Some(thread.session_id.as_str()) + ); let updated_git_info_json = updated_thread_json .get("gitInfo") .and_then(Value::as_object) @@ -170,6 +176,57 @@ async fn thread_metadata_update_rejects_empty_git_info_patch() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_metadata_update_rejects_ephemeral_thread() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ephemeral: Some(true), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let update_id = mcp + .send_thread_metadata_update_request(ThreadMetadataUpdateParams { + thread_id: thread.id.clone(), + git_info: Some(ThreadMetadataGitInfoUpdateParams { + sha: None, + branch: Some(Some("feature/ephemeral".to_string())), + origin_url: None, + }), + }) + .await?; + let update_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(update_id)), + ) + .await??; + + assert_eq!(update_err.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + update_err.error.message, + format!( + "ephemeral thread does not support metadata updates: {}", + thread.id + ) + ); + + Ok(()) +} + #[tokio::test] async fn thread_metadata_update_repairs_missing_sqlite_row_for_stored_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs b/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs index b41e2f1d18..951e4d74e9 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs @@ -22,11 +22,7 @@ use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadSetNameParams; use codex_app_server_protocol::ThreadSetNameResponse; use codex_core::find_thread_name_by_id; -use codex_core::find_thread_path_by_id_str; use codex_protocol::ThreadId; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::RolloutItem; -use codex_protocol::protocol::RolloutLine; use pretty_assertions::assert_eq; use std::path::Path; use tempfile::TempDir; @@ -85,10 +81,6 @@ async fn thread_name_updated_broadcasts_for_loaded_threads() -> Result<()> { read_notification_for_method(&mut ws2, "thread/name/updated").await?; assert_thread_name_updated(ws2_notification, &conversation_id, renamed)?; assert_legacy_thread_name(codex_home.path(), &conversation_id, renamed).await?; - assert_eq!( - thread_name_update_rollout_count(codex_home.path(), &conversation_id).await?, - 1 - ); assert_no_message(&mut ws1, Duration::from_millis(250)).await?; assert_no_message(&mut ws2, Duration::from_millis(250)).await?; @@ -141,10 +133,6 @@ async fn thread_name_updated_broadcasts_for_not_loaded_threads() -> Result<()> { read_notification_for_method(&mut ws2, "thread/name/updated").await?; assert_thread_name_updated(ws2_notification, &conversation_id, renamed)?; assert_legacy_thread_name(codex_home.path(), &conversation_id, renamed).await?; - assert_eq!( - thread_name_update_rollout_count(codex_home.path(), &conversation_id).await?, - 1 - ); assert_no_message(&mut ws1, Duration::from_millis(250)).await?; assert_no_message(&mut ws2, Duration::from_millis(250)).await?; @@ -206,23 +194,3 @@ async fn assert_legacy_thread_name( ); Ok(()) } - -async fn thread_name_update_rollout_count( - codex_home: &Path, - conversation_id: &str, -) -> Result { - let rollout_path = find_thread_path_by_id_str(codex_home, conversation_id) - .await? - .context("rollout path")?; - let contents = tokio::fs::read_to_string(rollout_path).await?; - Ok(contents - .lines() - .filter_map(|line| serde_json::from_str::(line).ok()) - .filter(|line| { - matches!( - line.item, - RolloutItem::EventMsg(EventMsg::ThreadNameUpdated(_)) - ) - }) - .count()) -} diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 589c7c330a..d6267b8fc0 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -31,8 +31,10 @@ use codex_app_server_protocol::ThreadSetNameResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::ThreadTurnsItemsListParams; use codex_app_server_protocol::ThreadTurnsListParams; use codex_app_server_protocol::ThreadTurnsListResponse; +use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; @@ -45,6 +47,7 @@ use codex_core::config::ConfigBuilder; use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; use codex_protocol::models::BaseInstructions; +use codex_protocol::protocol::AgentMessageEvent; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource as ProtocolSessionSource; @@ -174,6 +177,7 @@ async fn thread_read_can_include_turns() -> Result<()> { assert_eq!(thread.turns.len(), 1); let turn = &thread.turns[0]; assert_eq!(turn.status, TurnStatus::Completed); + assert_eq!(turn.items_view, TurnItemsView::Full); assert_eq!(turn.items.len(), 1, "expected user message item"); match &turn.items[0] { ThreadItem::UserMessage { content, .. } => { @@ -221,6 +225,7 @@ async fn thread_turns_list_can_page_backward_and_forward() -> Result<()> { cursor: None, limit: Some(2), sort_direction: Some(SortDirection::Desc), + items_view: None, }) .await?; let read_resp: JSONRPCResponse = timeout( @@ -234,6 +239,10 @@ async fn thread_turns_list_can_page_backward_and_forward() -> Result<()> { backwards_cursor, } = to_response::(read_resp)?; assert_eq!(turn_user_texts(&data), vec!["third", "second"]); + assert!( + data.iter() + .all(|turn| turn.items_view == TurnItemsView::Summary) + ); let next_cursor = next_cursor.expect("expected nextCursor for older turns"); let backwards_cursor = backwards_cursor.expect("expected backwardsCursor for newest turn"); @@ -243,6 +252,7 @@ async fn thread_turns_list_can_page_backward_and_forward() -> Result<()> { cursor: Some(next_cursor), limit: Some(10), sort_direction: Some(SortDirection::Desc), + items_view: None, }) .await?; let read_resp: JSONRPCResponse = timeout( @@ -261,6 +271,7 @@ async fn thread_turns_list_can_page_backward_and_forward() -> Result<()> { cursor: Some(backwards_cursor), limit: Some(10), sort_direction: Some(SortDirection::Asc), + items_view: None, }) .await?; let read_resp: JSONRPCResponse = timeout( @@ -274,6 +285,74 @@ async fn thread_turns_list_can_page_backward_and_forward() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_turns_list_supports_requested_items_view() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let filename_ts = "2025-01-05T12-00-00"; + let conversation_id = create_fake_rollout_with_text_elements( + codex_home.path(), + filename_ts, + "2025-01-05T12:00:00Z", + "first", + vec![], + Some("mock_provider"), + /*git_info*/ None, + )?; + let rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id); + append_agent_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "draft")?; + append_agent_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "final")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let full = read_single_turn_items_view( + &mut mcp, + conversation_id.as_str(), + Some(TurnItemsView::Full), + ) + .await?; + assert_eq!(full.items_view, TurnItemsView::Full); + assert_eq!( + turn_agent_texts(std::slice::from_ref(&full)), + vec!["draft", "final"] + ); + + let summary = read_single_turn_items_view( + &mut mcp, + conversation_id.as_str(), + Some(TurnItemsView::Summary), + ) + .await?; + assert_eq!(summary.items_view, TurnItemsView::Summary); + assert_eq!( + turn_user_texts(std::slice::from_ref(&summary)), + vec!["first"] + ); + assert_eq!( + turn_agent_texts(std::slice::from_ref(&summary)), + vec!["final"] + ); + + let not_loaded = read_single_turn_items_view( + &mut mcp, + conversation_id.as_str(), + Some(TurnItemsView::NotLoaded), + ) + .await?; + assert_eq!(not_loaded.items_view, TurnItemsView::NotLoaded); + assert!(not_loaded.items.is_empty()); + assert_eq!(not_loaded.id, full.id); + assert_eq!(not_loaded.status, full.status); + assert_eq!(not_loaded.started_at, full.started_at); + assert_eq!(not_loaded.completed_at, full.completed_at); + assert_eq!(not_loaded.duration_ms, full.duration_ms); + + Ok(()) +} + #[tokio::test] async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result<()> { let codex_home = TempDir::new()?; @@ -300,6 +379,7 @@ async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result< thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, + state_db: None, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source: SessionSource::Cli.into(), @@ -327,6 +407,7 @@ async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result< cursor: None, limit: Some(10), sort_direction: Some(SortDirection::Asc), + items_view: None, }, }) .await? @@ -363,6 +444,7 @@ async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_pa thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, + state_db: None, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source: SessionSource::Cli.into(), @@ -447,6 +529,7 @@ async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, + state_db: None, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source: SessionSource::Cli.into(), @@ -574,6 +657,7 @@ async fn thread_turns_list_rejects_cursor_when_anchor_turn_is_rolled_back() -> R cursor: None, limit: Some(2), sort_direction: Some(SortDirection::Desc), + items_view: None, }) .await?; let read_resp: JSONRPCResponse = timeout( @@ -598,6 +682,7 @@ async fn thread_turns_list_rejects_cursor_when_anchor_turn_is_rolled_back() -> R cursor: Some(backwards_cursor), limit: Some(10), sort_direction: Some(SortDirection::Asc), + items_view: None, }) .await?; let read_err: JSONRPCError = timeout( @@ -954,6 +1039,7 @@ async fn thread_turns_list_rejects_unmaterialized_loaded_thread() -> Result<()> cursor: None, limit: None, sort_direction: None, + items_view: None, }) .await?; let read_err: JSONRPCError = timeout( @@ -974,6 +1060,39 @@ async fn thread_turns_list_rejects_unmaterialized_loaded_thread() -> Result<()> Ok(()) } +#[tokio::test] +async fn thread_turns_items_list_returns_unsupported() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let read_id = mcp + .send_thread_turns_items_list_request(ThreadTurnsItemsListParams { + thread_id: "thr_123".to_string(), + turn_id: "turn_456".to_string(), + cursor: None, + limit: None, + sort_direction: None, + }) + .await?; + let read_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(read_id)), + ) + .await??; + + assert_eq!(read_err.error.code, -32601); + assert_eq!( + read_err.error.message, + "thread/turns/items/list is not supported yet" + ); + + Ok(()) +} + #[tokio::test] async fn thread_read_reports_system_error_idle_flag_after_failed_turn() -> Result<()> { let server = responses::start_mock_server().await; @@ -1059,6 +1178,24 @@ fn append_user_message(path: &Path, timestamp: &str, text: &str) -> std::io::Res ) } +fn append_agent_message(path: &Path, timestamp: &str, text: &str) -> anyhow::Result<()> { + let mut file = std::fs::OpenOptions::new().append(true).open(path)?; + writeln!( + file, + "{}", + json!({ + "timestamp": timestamp, + "type": "event_msg", + "payload": serde_json::to_value(EventMsg::AgentMessage(AgentMessageEvent { + message: text.to_string(), + phase: None, + memory_citation: None, + }))?, + }) + )?; + Ok(()) +} + fn append_thread_rollback(path: &Path, timestamp: &str, num_turns: u32) -> std::io::Result<()> { let mut file = std::fs::OpenOptions::new().append(true).open(path)?; writeln!( @@ -1075,6 +1212,31 @@ fn append_thread_rollback(path: &Path, timestamp: &str, num_turns: u32) -> std:: ) } +async fn read_single_turn_items_view( + mcp: &mut McpProcess, + thread_id: &str, + items_view: Option, +) -> anyhow::Result { + let read_id = mcp + .send_thread_turns_list_request(ThreadTurnsListParams { + thread_id: thread_id.to_string(), + cursor: None, + limit: Some(10), + sort_direction: Some(SortDirection::Asc), + items_view, + }) + .await?; + let read_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(read_id)), + ) + .await??; + let ThreadTurnsListResponse { mut data, .. } = + to_response::(read_resp)?; + assert_eq!(data.len(), 1); + Ok(data.remove(0)) +} + fn turn_user_texts(turns: &[codex_app_server_protocol::Turn]) -> Vec<&str> { turns .iter() @@ -1091,6 +1253,17 @@ fn turn_user_texts(turns: &[codex_app_server_protocol::Turn]) -> Vec<&str> { .collect() } +fn turn_agent_texts(turns: &[codex_app_server_protocol::Turn]) -> Vec<&str> { + turns + .iter() + .flat_map(|turn| &turn.items) + .filter_map(|item| match item { + ThreadItem::AgentMessage { text, .. } => Some(text.as_str()), + _ => None, + }) + .collect() +} + struct InMemoryThreadStoreId { store_id: String, } @@ -1110,6 +1283,7 @@ async fn seed_pathless_store_thread( thread_id, forked_from_id: None, source: ProtocolSessionSource::Cli, + thread_source: None, base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), metadata: ThreadPersistenceMetadata { @@ -1130,7 +1304,7 @@ async fn seed_pathless_store_thread( .update_thread_metadata(UpdateThreadMetadataParams { thread_id, patch: ThreadMetadataPatch { - name: Some("named pathless thread".to_string()), + name: Some(Some("named pathless thread".to_string())), ..Default::default() }, include_archived: true, 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 48673387b8..64dfe0beb7 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -15,6 +15,7 @@ use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use chrono::Utc; use codex_app_server_protocol::AskForApproval; +use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::CommandExecutionApprovalDecision; use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; use codex_app_server_protocol::FileChangeApprovalDecision; @@ -38,9 +39,11 @@ use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus; +use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; @@ -49,10 +52,14 @@ use codex_config::types::AuthCredentialsStoreMode; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_protocol::ThreadId; use codex_protocol::config_types::Personality; +use codex_protocol::mcp::CallToolResult; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentMessageEvent; use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ImageGenerationEndEvent; +use codex_protocol::protocol::McpInvocation; +use codex_protocol::protocol::McpToolCallEndEvent; use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource as RolloutSessionSource; @@ -75,6 +82,7 @@ use std::io::Write; use std::path::Path; use std::path::PathBuf; use std::process::Command; +use std::time::Duration; use tempfile::TempDir; use tokio::time::timeout; use uuid::Uuid; @@ -93,7 +101,6 @@ use super::analytics::wait_for_analytics_payload; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); -const INTERNAL_ERROR_CODE: i64 = -32603; const CODEX_5_2_INSTRUCTIONS_TEMPLATE_DEFAULT: &str = "You are Codex, a coding agent based on GPT-5. You and the user share the same workspace and collaborate to achieve the user's goals."; fn normalized_existing_path(path: impl AsRef) -> Result { @@ -239,15 +246,20 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { create_config_toml_with_chatgpt_base_url(codex_home.path(), &server.uri(), &server.uri())?; mount_analytics_capture(&server, codex_home.path()).await?; - let conversation_id = create_fake_rollout_with_text_elements( + let conversation_id = create_fake_rollout( codex_home.path(), "2025-01-05T12-00-00", "2025-01-05T12:00:00Z", "Saved user message", - Vec::new(), Some("mock_provider"), /*git_info*/ None, )?; + set_thread_source_on_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + &conversation_id, + "user", + )?; let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -264,10 +276,35 @@ async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { ) .await??; let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; + assert!( + !thread.session_id.is_empty(), + "session id should not be empty" + ); + assert_eq!(thread.thread_source, Some(ThreadSource::User)); 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.3-codex", "resumed"); + assert_basic_thread_initialized_event(event, &thread.id, "gpt-5.3-codex", "resumed", "user"); + assert_eq!(event["event_params"]["thread_source"], "user"); + Ok(()) +} + +fn set_thread_source_on_fake_rollout( + codex_home: &std::path::Path, + filename_ts: &str, + thread_id: &str, + thread_source: &str, +) -> Result<()> { + let path = rollout_path(codex_home, filename_ts, thread_id); + let contents = std::fs::read_to_string(&path)?; + let mut lines = contents.lines(); + let session_meta = lines + .next() + .ok_or_else(|| anyhow::anyhow!("fake rollout missing session meta"))?; + let mut session_meta: serde_json::Value = serde_json::from_str(session_meta)?; + session_meta["payload"]["thread_source"] = serde_json::json!(thread_source); + let remaining = lines.collect::>().join("\n"); + std::fs::write(&path, format!("{session_meta}\n{remaining}\n"))?; Ok(()) } @@ -345,6 +382,203 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_resume_redacts_payloads_for_chatgpt_remote_clients() -> Result<()> { + for client_name in ["codex_chatgpt_android_remote", "codex_chatgpt_ios_remote"] { + let remote_thread = resume_redaction_fixture(Some(client_name)).await?; + let remote_turn = remote_thread + .turns + .first() + .expect("remote resume should include a turn"); + let remote_mcp_item = remote_turn + .items + .iter() + .find(|item| matches!(item, ThreadItem::McpToolCall { .. })) + .expect("remote resume should include redacted MCP item"); + let ThreadItem::McpToolCall { + arguments, + result, + error, + .. + } = remote_mcp_item + else { + unreachable!("matched MCP item"); + }; + assert_eq!(arguments, &json!("[redacted]")); + let result = result.as_ref().expect("redacted MCP result"); + assert_eq!( + result.content, + vec![json!({ + "type": "text", + "text": "[redacted]", + })] + ); + assert_eq!(result.structured_content, None); + assert_eq!(result.meta, None); + assert_eq!(error, &None); + assert!( + !remote_turn + .items + .iter() + .any(|item| matches!(item, ThreadItem::ImageGeneration { .. })), + "remote resume should drop image generation items for {client_name}" + ); + } + + let normal_thread = resume_redaction_fixture(Some("some_other_client")).await?; + let normal_turn = normal_thread + .turns + .first() + .expect("normal resume should include a turn"); + let normal_mcp_item = normal_turn + .items + .iter() + .find(|item| matches!(item, ThreadItem::McpToolCall { .. })) + .expect("normal resume should include MCP item"); + let ThreadItem::McpToolCall { + arguments, result, .. + } = normal_mcp_item + else { + unreachable!("matched MCP item"); + }; + assert_eq!(arguments, &json!({"secret":"argument"})); + let result = result.as_ref().expect("normal MCP result"); + assert_eq!( + result.content, + vec![json!({ + "type": "text", + "text": "secret result", + })] + ); + assert_eq!( + result.structured_content, + Some(json!({"secret":"structured"})) + ); + assert_eq!(result.meta, Some(json!({"secret":"meta"}))); + assert!( + normal_turn.items.iter().any(|item| matches!( + item, + ThreadItem::ImageGeneration { + result, + revised_prompt, + .. + } if result == "base64-image-result" + && revised_prompt.as_deref() == Some("secret revised prompt") + )), + "normal resume should keep image generation items" + ); + + Ok(()) +} + +async fn resume_redaction_fixture( + client_name: Option<&str>, +) -> Result { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let filename_ts = "2025-01-05T12-00-00"; + let meta_rfc3339 = "2025-01-05T12:00:00Z"; + let conversation_id = create_fake_rollout( + codex_home.path(), + filename_ts, + meta_rfc3339, + "Saved user message", + Some("mock_provider"), + /*git_info*/ None, + )?; + append_resume_redaction_history( + codex_home.path(), + filename_ts, + meta_rfc3339, + &conversation_id, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + if let Some(client_name) = client_name { + let _ = timeout( + DEFAULT_READ_TIMEOUT, + mcp.initialize_with_client_info(ClientInfo { + name: client_name.to_string(), + title: None, + version: "0.1.0".to_string(), + }), + ) + .await??; + } else { + 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)?; + Ok(thread) +} + +fn append_resume_redaction_history( + codex_home: &Path, + filename_ts: &str, + meta_rfc3339: &str, + conversation_id: &str, +) -> Result<()> { + let rollout_file_path = rollout_path(codex_home, filename_ts, conversation_id); + let persisted_rollout = std::fs::read_to_string(&rollout_file_path)?; + let appended_rollout = [ + EventMsg::McpToolCallEnd(McpToolCallEndEvent { + call_id: "mcp-1".to_string(), + invocation: McpInvocation { + server: "docs".to_string(), + tool: "lookup".to_string(), + arguments: Some(json!({"secret":"argument"})), + }, + mcp_app_resource_uri: Some("ui://widget/lookup.html".to_string()), + duration: Duration::from_millis(8), + result: Ok(CallToolResult { + content: vec![json!({ + "type": "text", + "text": "secret result", + })], + structured_content: Some(json!({"secret":"structured"})), + is_error: Some(false), + meta: Some(json!({"secret":"meta"})), + }), + }), + EventMsg::ImageGenerationEnd(ImageGenerationEndEvent { + call_id: "ig-1".to_string(), + status: "completed".to_string(), + revised_prompt: Some("secret revised prompt".to_string()), + result: "base64-image-result".to_string(), + saved_path: Some(test_absolute_path("/tmp/ig-1.png")), + }), + ] + .into_iter() + .map(|payload| { + Ok(json!({ + "timestamp": meta_rfc3339, + "type": "event_msg", + "payload": serde_json::to_value(payload)?, + }) + .to_string()) + }) + .collect::>>()? + .join("\n"); + std::fs::write( + &rollout_file_path, + format!("{persisted_rollout}{appended_rollout}\n"), + )?; + Ok(()) +} + #[tokio::test] async fn thread_resume_can_skip_turns_for_metadata_only_resume() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -385,7 +619,7 @@ async fn thread_resume_can_skip_turns_for_metadata_only_resume() -> Result<()> { } #[tokio::test] -async fn thread_resume_emits_active_goal_update_before_continuation() -> Result<()> { +async fn thread_resume_keeps_paused_goal_paused() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; @@ -477,12 +711,12 @@ async fn thread_resume_emits_active_goal_update_before_continuation() -> Result< let ServerNotification::ThreadGoalUpdated(notification) = notification else { anyhow::bail!("expected thread goal update notification"); }; - assert_eq!(notification.goal.status, ThreadGoalStatus::Active); + assert_eq!(notification.goal.status, ThreadGoalStatus::Paused); assert!( !mcp.pending_notification_methods() .iter() .any(|method| method == "turn/started"), - "goal continuation should start only after the resume goal snapshot" + "paused goal should not continue after thread resume" ); Ok(()) @@ -586,6 +820,102 @@ async fn thread_goal_set_preserves_budget_limited_same_objective() -> Result<()> Ok(()) } +#[tokio::test] +async fn thread_goal_set_edits_objective_without_resetting_usage() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config.replace("personality = true\n", "personality = true\ngoals = true\n"), + )?; + let thread_id = create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "materialized thread", + Some("mock_provider"), + /*git_info*/ None, + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let goal_id = mcp + .send_raw_request( + "thread/goal/set", + Some(json!({ + "threadId": thread_id, + "objective": "keep polishing", + "status": "active", + "tokenBudget": 40, + })), + ) + .await?; + let goal_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(goal_id)), + ) + .await??; + let goal: ThreadGoalSetResponse = to_response(goal_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/updated"), + ) + .await??; + + let state_db = + StateRuntime::init(codex_home.path().to_path_buf(), "mock_provider".into()).await?; + let thread_id = ThreadId::from_string(&thread_id)?; + let persisted_goal = state_db + .get_thread_goal(thread_id) + .await? + .expect("goal should exist"); + state_db + .account_thread_goal_usage( + thread_id, + /*time_delta_seconds*/ 12, + /*token_delta*/ 50, + codex_state::ThreadGoalAccountingMode::ActiveOnly, + Some(persisted_goal.goal_id.as_str()), + ) + .await?; + + let edit_id = mcp + .send_raw_request( + "thread/goal/set", + Some(json!({ + "threadId": thread_id.to_string(), + "objective": "keep polishing with clearer wording", + "status": "active", + "tokenBudget": 40, + })), + ) + .await?; + let edit_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(edit_id)), + ) + .await??; + let edit: ThreadGoalSetResponse = to_response(edit_resp)?; + let updated_goal = state_db + .get_thread_goal(thread_id) + .await? + .expect("goal should still exist"); + + assert_eq!(persisted_goal.goal_id, updated_goal.goal_id); + assert_eq!(edit.goal.objective, "keep polishing with clearer wording"); + assert_eq!(edit.goal.status, ThreadGoalStatus::BudgetLimited); + assert_eq!(edit.goal.token_budget, Some(40)); + assert_eq!(edit.goal.tokens_used, 50); + assert_eq!(edit.goal.time_used_seconds, 12); + assert_eq!(edit.goal.created_at, goal.goal.created_at); + + Ok(()) +} + #[tokio::test] async fn thread_goal_clear_deletes_goal_and_notifies() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -713,37 +1043,6 @@ async fn thread_goal_clear_deletes_goal_and_notifies() -> Result<()> { Ok(()) } -#[tokio::test] -async fn thread_resume_by_path_uses_remote_thread_store_error() -> Result<()> { - let server = create_mock_responses_server_repeating_assistant("Done").await; - let codex_home = TempDir::new()?; - create_config_toml_with_remote_thread_store(codex_home.path(), &server.uri())?; - - let mut mcp = McpProcess::new(codex_home.path()).await?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - - let resume_id = mcp - .send_thread_resume_request(ThreadResumeParams { - thread_id: "ignored-when-path-is-present".to_string(), - path: Some(PathBuf::from("sessions/2025/01/05/rollout.jsonl")), - ..Default::default() - }) - .await?; - let resume_err: JSONRPCError = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_error_message(RequestId::Integer(resume_id)), - ) - .await??; - - assert_eq!(resume_err.error.code, INTERNAL_ERROR_CODE); - assert_eq!( - resume_err.error.message, - "failed to read thread: thread-store internal error: remote thread store does not support read_thread_by_rollout_path" - ); - - Ok(()) -} - #[tokio::test] async fn thread_resume_emits_restored_token_usage_before_next_turn() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -1178,6 +1477,7 @@ stream_max_retries = 0 originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source: RolloutSessionSource::Cli, + thread_source: None, agent_path: None, agent_nickname: None, agent_role: None, @@ -1629,6 +1929,7 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { .await??; let TurnStartResponse { turn: running_turn } = to_response::(running_turn_resp)?; + assert_eq!(running_turn.items_view, TurnItemsView::NotLoaded); timeout( DEFAULT_READ_TIMEOUT, primary.read_stream_until_notification_message("turn/started"), @@ -2894,36 +3195,6 @@ stream_max_retries = 0 ) } -fn create_config_toml_with_remote_thread_store( - codex_home: &std::path::Path, - server_uri: &str, -) -> std::io::Result<()> { - let config_toml = codex_home.join("config.toml"); - std::fs::write( - config_toml, - format!( - r#" -model = "gpt-5.3-codex" -approval_policy = "never" -sandbox_mode = "read-only" -experimental_thread_store_endpoint = "http://127.0.0.1:1" - -model_provider = "mock_provider" - -[features] -personality = true - -[model_providers.mock_provider] -name = "Mock provider for test" -base_url = "{server_uri}/v1" -wire_api = "responses" -request_max_retries = 0 -stream_max_retries = 0 -"# - ), - ) -} - fn create_config_toml_with_chatgpt_base_url( codex_home: &std::path::Path, server_uri: &str, diff --git a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs index 3487b9e36a..5f79db0e26 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_rollback.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_rollback.rs @@ -119,11 +119,16 @@ async fn thread_rollback_drops_last_turns_and_persists_to_rollout() -> Result<() .and_then(Value::as_object) .expect("thread/rollback result.thread must be an object"); assert_eq!(rolled_back_thread.name, None); + assert_eq!(rolled_back_thread.session_id, thread.session_id); assert_eq!( thread_json.get("name"), Some(&Value::Null), "thread/rollback must serialize `name: null` when unset" ); + assert_eq!( + thread_json.get("sessionId").and_then(Value::as_str), + Some(thread.session_id.as_str()) + ); assert_eq!(rolled_back_thread.turns.len(), 1); assert_eq!(rolled_back_thread.status, ThreadStatus::Idle); diff --git a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs index 4580c1879d..b7cfba2f95 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs @@ -15,6 +15,9 @@ use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::SortDirection; +use codex_app_server_protocol::ThreadForkParams; +use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; @@ -22,6 +25,8 @@ use codex_app_server_protocol::ThreadShellCommandParams; use codex_app_server_protocol::ThreadShellCommandResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadTurnsListParams; +use codex_app_server_protocol::ThreadTurnsListResponse; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; @@ -38,7 +43,8 @@ use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] -async fn thread_shell_command_runs_as_standalone_turn_and_persists_history() -> Result<()> { +async fn thread_shell_command_history_responses_exclude_persisted_command_executions() -> Result<()> +{ let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); std::fs::create_dir(&codex_home)?; @@ -126,7 +132,7 @@ async fn thread_shell_command_runs_as_standalone_turn_and_persists_history() -> let read_id = mcp .send_thread_read_request(ThreadReadParams { - thread_id: thread.id, + thread_id: thread.id.clone(), include_turns: true, }) .await?; @@ -137,22 +143,41 @@ async fn thread_shell_command_runs_as_standalone_turn_and_persists_history() -> .await??; let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; assert_eq!(thread.turns.len(), 1); - let ThreadItem::CommandExecution { - source, - status, - aggregated_output, - .. - } = thread.turns[0] - .items - .iter() - .find(|item| matches!(item, ThreadItem::CommandExecution { .. })) - .expect("expected persisted command execution item") - else { - unreachable!("matched command execution item"); - }; - assert_eq!(source, &CommandExecutionSource::UserShell); - assert_eq!(status, &CommandExecutionStatus::Completed); - assert_eq!(aggregated_output.as_deref(), Some(expected_output.as_str())); + assert_no_command_executions(&thread.turns[0].items, "thread/read"); + + let turns_list_id = mcp + .send_thread_turns_list_request(ThreadTurnsListParams { + thread_id: thread.id.clone(), + cursor: None, + limit: None, + sort_direction: Some(SortDirection::Asc), + items_view: None, + }) + .await?; + let turns_list_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turns_list_id)), + ) + .await??; + let ThreadTurnsListResponse { data, .. } = + to_response::(turns_list_resp)?; + assert_eq!(data.len(), 1); + assert_no_command_executions(&data[0].items, "thread/turns/list"); + + let fork_id = mcp + .send_thread_fork_request(ThreadForkParams { + thread_id: thread.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)?; + assert_eq!(thread.turns.len(), 1); + assert_no_command_executions(&thread.turns[0].items, "thread/fork"); Ok(()) } @@ -307,23 +332,20 @@ async fn thread_shell_command_uses_existing_active_turn() -> Result<()> { .await??; let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; assert_eq!(thread.turns.len(), 1); - assert!( - thread.turns[0].items.iter().any(|item| { - matches!( - item, - ThreadItem::CommandExecution { - source: CommandExecutionSource::UserShell, - aggregated_output, - .. - } if aggregated_output.as_deref() == Some(expected_output.as_str()) - ) - }), - "expected active-turn shell command to be persisted on the existing turn" - ); + assert_no_command_executions(&thread.turns[0].items, "thread/read"); Ok(()) } +fn assert_no_command_executions(items: &[ThreadItem], context: &str) { + assert!( + items + .iter() + .all(|item| !matches!(item, ThreadItem::CommandExecution { .. })), + "{context} should always exclude command executions from returned turns" + ); +} + fn current_shell_output_command(text: &str) -> Result<(String, String)> { let command_and_output = match default_user_shell().name() { "powershell" => { 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 d8a50b88a4..78155d8c9a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -6,6 +6,7 @@ use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::AskForApproval; +use codex_app_server_protocol::DeprecationNoticeNotification; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; @@ -14,6 +15,7 @@ use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; @@ -26,7 +28,6 @@ use codex_core::config::set_project_trust_level; use codex_exec_server::LOCAL_FS; use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; -use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; @@ -50,6 +51,46 @@ use super::analytics::wait_for_analytics_payload; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const INVALID_REQUEST_ERROR_CODE: i64 = -32600; +#[tokio::test] +async fn thread_start_deprecates_persist_extended_history_true() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + persist_extended_history: true, + ..Default::default() + }) + .await?; + + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("deprecationNotice"), + ) + .await??; + let notice: DeprecationNoticeNotification = serde_json::from_value( + notification + .params + .expect("deprecationNotice params should be present"), + )?; + assert_eq!( + notice.summary, + "persistExtendedHistory is deprecated and ignored" + ); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + + Ok(()) +} + #[tokio::test] async fn thread_start_creates_thread_and_emits_started() -> Result<()> { // Provide a mock server and config so model wiring is valid. @@ -66,6 +107,7 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { let req_id = mcp .send_thread_start_request(ThreadStartParams { model: Some("gpt-5.2".to_string()), + thread_source: Some(ThreadSource::User), ..Default::default() }) .await?; @@ -82,6 +124,10 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { model_provider, .. } = to_response::(resp)?; + assert!( + !thread.session_id.is_empty(), + "session id should not be empty" + ); assert!(!thread.id.is_empty(), "thread id should not be empty"); assert!( thread.preview.is_empty(), @@ -97,6 +143,7 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { "new persistent threads should not be ephemeral" ); assert_eq!(thread.status, ThreadStatus::Idle); + assert_eq!(thread.thread_source, Some(ThreadSource::User)); let thread_path = thread.path.clone().expect("thread path should be present"); assert!(thread_path.is_absolute(), "thread path should be absolute"); assert!( @@ -109,16 +156,31 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { .get("thread") .and_then(Value::as_object) .expect("thread/start result.thread must be an object"); + assert_eq!( + thread_json.get("sessionId").and_then(Value::as_str), + Some(thread.session_id.as_str()), + "new threads should serialize `sessionId` on the thread object" + ); assert_eq!( thread_json.get("name"), Some(&Value::Null), "new threads should serialize `name: null`" ); + assert_eq!( + resp_result.get("sessionId"), + None, + "thread/start should not serialize a top-level `sessionId`" + ); assert_eq!( thread_json.get("ephemeral").and_then(Value::as_bool), Some(false), "new persistent threads should serialize `ephemeral: false`" ); + assert_eq!( + thread_json.get("threadSource").and_then(Value::as_str), + Some("user"), + "new threads should serialize the caller-supplied thread origin" + ); assert_eq!(thread.name, None); // A corresponding thread/started notification should arrive. @@ -160,6 +222,13 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { Some(false), "thread/started should serialize `ephemeral: false` for new persistent threads" ); + assert_eq!( + started_thread_json + .get("threadSource") + .and_then(Value::as_str), + Some("user"), + "thread/started should preserve the caller-supplied thread origin" + ); let started: ThreadStartedNotification = serde_json::from_value(notif.params.expect("params must be present"))?; assert_eq!(started.thread, thread); @@ -271,7 +340,10 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let req_id = mcp - .send_thread_start_request(ThreadStartParams::default()) + .send_thread_start_request(ThreadStartParams { + thread_source: Some(ThreadSource::User), + ..Default::default() + }) .await?; let resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, @@ -283,7 +355,7 @@ async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> { 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"); + assert_basic_thread_initialized_event(event, &thread.id, "mock-model", "new", "user"); Ok(()) } @@ -329,7 +401,7 @@ model_reasoning_effort = "high" } #[tokio::test] -async fn thread_start_accepts_flex_service_tier() -> Result<()> { +async fn thread_start_accepts_arbitrary_service_tier_id() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; @@ -338,9 +410,10 @@ async fn thread_start_accepts_flex_service_tier() -> Result<()> { let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + let service_tier_id = "experimental-tier-id".to_string(); let req_id = mcp .send_thread_start_request(ThreadStartParams { - service_tier: Some(Some(ServiceTier::Flex)), + service_tier: Some(Some(service_tier_id.clone())), ..Default::default() }) .await?; @@ -352,7 +425,7 @@ async fn thread_start_accepts_flex_service_tier() -> Result<()> { .await??; let ThreadStartResponse { service_tier, .. } = to_response::(resp)?; - assert_eq!(service_tier, Some(ServiceTier::Flex)); + assert_eq!(service_tier, Some(service_tier_id)); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_status.rs b/codex-rs/app-server/tests/suite/v2/thread_status.rs index ad90e4900a..957969c3ea 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_status.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_status.rs @@ -145,6 +145,7 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> { }, Some(InitializeCapabilities { experimental_api: true, + request_attestation: false, opt_out_notification_methods: Some(vec!["thread/status/changed".to_string()]), }), ), diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index b2ae60ae35..dfc79e9d48 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -2,6 +2,12 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; +use codex_app_server::in_process; +use codex_app_server::in_process::InProcessStartArgs; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadArchiveParams; @@ -15,17 +21,36 @@ use codex_app_server_protocol::ThreadUnarchivedNotification; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput; +use codex_arg0::Arg0DispatchPaths; +use codex_config::CloudRequirementsLoader; +use codex_config::LoaderOverrides; +use codex_core::config::ConfigBuilder; use codex_core::find_archived_thread_path_by_id_str; use codex_core::find_thread_path_by_id_str; +use codex_exec_server::EnvironmentManager; +use codex_feedback::CodexFeedback; +use codex_protocol::ThreadId; +use codex_protocol::models::BaseInstructions; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadMemoryMode; +use codex_thread_store::CreateThreadParams; +use codex_thread_store::InMemoryThreadStore; +use codex_thread_store::ThreadEventPersistenceMode; +use codex_thread_store::ThreadMetadataPatch; +use codex_thread_store::ThreadPersistenceMetadata; +use codex_thread_store::ThreadStore; +use codex_thread_store::UpdateThreadMetadataParams; use pretty_assertions::assert_eq; use serde_json::Value; use std::fs::FileTimes; use std::fs::OpenOptions; use std::path::Path; +use std::sync::Arc; use std::time::Duration; use std::time::SystemTime; use tempfile::TempDir; use tokio::time::timeout; +use uuid::Uuid; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); @@ -75,9 +100,10 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result ) .await??; - let found_rollout_path = find_thread_path_by_id_str(codex_home.path(), &thread.id) - .await? - .expect("expected rollout path for thread id to exist"); + let found_rollout_path = + find_thread_path_by_id_str(codex_home.path(), &thread.id, /*state_db_ctx*/ None) + .await? + .expect("expected rollout path for thread id to exist"); assert_paths_match_on_disk(&found_rollout_path, &rollout_path)?; let archive_id = mcp @@ -92,9 +118,13 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result .await??; let _: ThreadArchiveResponse = to_response::(archive_resp)?; - let archived_path = find_archived_thread_path_by_id_str(codex_home.path(), &thread.id) - .await? - .expect("expected archived rollout path for thread id to exist"); + let archived_path = find_archived_thread_path_by_id_str( + codex_home.path(), + &thread.id, + /*state_db_ctx*/ None, + ) + .await? + .expect("expected archived rollout path for thread id to exist"); let archived_path_display = archived_path.display(); assert!( archived_path.exists(), @@ -167,11 +197,139 @@ async fn thread_unarchive_moves_rollout_back_into_sessions_directory() -> Result Ok(()) } +#[tokio::test] +async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { + let codex_home = TempDir::new()?; + let store_id = Uuid::new_v4().to_string(); + create_config_toml_with_in_memory_thread_store(codex_home.path(), &store_id)?; + let store = InMemoryThreadStore::for_id(store_id.clone()); + let _in_memory_store = InMemoryThreadStoreId { store_id }; + let thread_id = ThreadId::from_string("00000000-0000-4000-8000-000000000126")?; + let parent_thread_id = ThreadId::from_string("00000000-0000-4000-8000-000000000127")?; + store + .create_thread(CreateThreadParams { + thread_id, + forked_from_id: Some(parent_thread_id), + source: SessionSource::Cli, + thread_source: None, + base_instructions: BaseInstructions::default(), + dynamic_tools: Vec::new(), + metadata: ThreadPersistenceMetadata { + cwd: None, + model_provider: "test-provider".to_string(), + memory_mode: ThreadMemoryMode::Disabled, + }, + event_persistence_mode: ThreadEventPersistenceMode::default(), + }) + .await?; + store + .update_thread_metadata(UpdateThreadMetadataParams { + thread_id, + patch: ThreadMetadataPatch { + name: Some(Some("named pathless thread".to_string())), + ..Default::default() + }, + include_archived: true, + }) + .await?; + + let loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .loader_overrides(loader_overrides.clone()) + .build() + .await?; + let client = in_process::start(InProcessStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: Arc::new(config), + cli_overrides: Vec::new(), + loader_overrides, + cloud_requirements: CloudRequirementsLoader::default(), + thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), + feedback: CodexFeedback::new(), + log_db: None, + state_db: None, + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), + config_warnings: Vec::new(), + session_source: SessionSource::Cli, + enable_codex_api_key_env: false, + initialize: InitializeParams { + client_info: ClientInfo { + name: "codex-app-server-tests".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + capabilities: Some(InitializeCapabilities { + experimental_api: true, + ..Default::default() + }), + }, + channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await?; + + let result = client + .request(ClientRequest::ThreadUnarchive { + request_id: RequestId::Integer(1), + params: ThreadUnarchiveParams { + thread_id: thread_id.to_string(), + }, + }) + .await? + .expect("thread/unarchive should succeed"); + let ThreadUnarchiveResponse { thread } = serde_json::from_value(result)?; + + assert_eq!(thread.id, thread_id.to_string()); + assert_eq!(thread.path, None); + assert_eq!(thread.forked_from_id, Some(parent_thread_id.to_string())); + assert_eq!(thread.name, Some("named pathless thread".to_string())); + + client.shutdown().await?; + Ok(()) +} + fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); std::fs::write(config_toml, config_contents(server_uri)) } +struct InMemoryThreadStoreId { + store_id: String, +} + +impl Drop for InMemoryThreadStoreId { + fn drop(&mut self) { + InMemoryThreadStore::remove_id(&self.store_id); + } +} + +fn create_config_toml_with_in_memory_thread_store( + codex_home: &Path, + store_id: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +experimental_thread_store = {{ type = "in_memory", id = "{store_id}" }} + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "http://127.0.0.1:1/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} + fn config_contents(server_uri: &str) -> String { format!( r#"model = "mock-model" diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 3c5bbd3b61..524b795b81 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -40,10 +40,12 @@ use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestResolvedNotification; use codex_app_server_protocol::TextElement; use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnEnvironmentParams; +use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStartedNotification; @@ -52,6 +54,7 @@ use codex_app_server_protocol::UserInput as V2UserInput; use codex_app_server_protocol::WarningNotification; use codex_config::config_toml::ConfigToml; use codex_core::personality_migration::PERSONALITY_MIGRATION_FILENAME; +use codex_core::test_support::all_model_presets; use codex_features::FEATURES; use codex_features::Feature; use codex_protocol::config_types::CollaborationMode; @@ -115,6 +118,7 @@ async fn turn_start_sends_originator_header() -> Result<()> { let thread_req = mcp .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), + thread_source: Some(ThreadSource::User), ..Default::default() }) .await?; @@ -182,6 +186,7 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> { let thread_req = mcp .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), + thread_source: Some(ThreadSource::User), ..Default::default() }) .await?; @@ -354,6 +359,76 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills( Ok(()) } +#[tokio::test] +async fn turn_start_sends_service_tier_id_to_model_request() -> Result<()> { + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response_mock = responses::mount_sse_once(&server, body).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::default(), + )?; + write_models_cache(codex_home.path())?; + let service_tier_model = all_model_presets() + .iter() + .find(|preset| preset.show_in_picker && !preset.service_tiers.is_empty()) + .expect("bundled model catalog should include a picker model with service tiers"); + let service_tier_id = service_tier_model.service_tiers[0].id.clone(); + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some(service_tier_model.id.clone()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + service_tier: Some(Some(service_tier_id.clone())), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + assert_eq!( + response_mock.single_request().body_json()["service_tier"], + json!(service_tier_id) + ); + + Ok(()) +} + #[tokio::test] async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Result<()> { let server = responses::start_mock_server().await; @@ -463,6 +538,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { let thread_req = mcp .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), + thread_source: Some(ThreadSource::User), ..Default::default() }) .await?; @@ -868,6 +944,8 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<( codex_app_server_protocol::TurnStatus::InProgress ); assert_eq!(started.turn.id, turn.id); + assert_eq!(started.turn.items_view, TurnItemsView::NotLoaded); + assert!(started.turn.items.is_empty()); let completed_notif: JSONRPCNotification = timeout( DEFAULT_READ_TIMEOUT, @@ -882,6 +960,8 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<( assert_eq!(completed.thread_id, thread.id); assert_eq!(completed.turn.id, turn.id); assert_eq!(completed.turn.status, TurnStatus::Completed); + assert_eq!(completed.turn.items_view, TurnItemsView::NotLoaded); + assert!(completed.turn.items.is_empty()); // Send a second turn that exercises the overrides path: change the model. let turn_req2 = mcp @@ -915,6 +995,8 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<( assert_eq!(started2.thread_id, thread.id); assert_eq!(started2.turn.id, turn2.id); assert_eq!(started2.turn.status, TurnStatus::InProgress); + assert_eq!(started2.turn.items_view, TurnItemsView::NotLoaded); + assert!(started2.turn.items.is_empty()); let completed_notif2: JSONRPCNotification = timeout( DEFAULT_READ_TIMEOUT, @@ -929,6 +1011,8 @@ async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<( assert_eq!(completed2.thread_id, thread.id); assert_eq!(completed2.turn.id, turn2.id); assert_eq!(completed2.turn.status, TurnStatus::Completed); + assert_eq!(completed2.turn.items_view, TurnItemsView::NotLoaded); + assert!(completed2.turn.items.is_empty()); Ok(()) } @@ -1907,7 +1991,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { } #[tokio::test] -async fn turn_start_resolves_sticky_thread_environments_and_turn_overrides() -> Result<()> { +async fn turn_start_resolves_sticky_thread_local_environment_and_turn_overrides() -> Result<()> { let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); std::fs::create_dir(&codex_home)?; @@ -1916,12 +2000,16 @@ async fn turn_start_resolves_sticky_thread_environments_and_turn_overrides() -> let server = create_mock_responses_server_repeating_assistant("done").await; create_config_toml(&codex_home, &server.uri(), "never", &BTreeMap::default())?; + std::fs::write( + codex_home.join("environments.toml"), + r#" +[[environments]] +id = "remote" +url = "ws://127.0.0.1:1" +"#, + )?; - let mut mcp = McpProcess::new_with_env( - &codex_home, - &[("CODEX_EXEC_SERVER_URL", Some("http://127.0.0.1:1"))], - ) - .await?; + let mut mcp = McpProcess::new(&codex_home).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; for case in [ @@ -1940,16 +2028,6 @@ async fn turn_start_resolves_sticky_thread_environments_and_turn_overrides() -> sticky: Some(&["local"]), turn: None, }, - EnvironmentSelectionCase { - name: "sticky_remote_turn_unset", - sticky: Some(&["remote"]), - turn: None, - }, - EnvironmentSelectionCase { - name: "sticky_local_remote_turn_unset", - sticky: Some(&["local", "remote"]), - turn: None, - }, EnvironmentSelectionCase { name: "sticky_local_turn_empty", sticky: Some(&["local"]), @@ -1960,21 +2038,6 @@ async fn turn_start_resolves_sticky_thread_environments_and_turn_overrides() -> sticky: Some(&[]), turn: Some(&["local"]), }, - EnvironmentSelectionCase { - name: "sticky_local_turn_remote", - sticky: Some(&["local"]), - turn: Some(&["remote"]), - }, - EnvironmentSelectionCase { - name: "sticky_remote_turn_local", - sticky: Some(&["remote"]), - turn: Some(&["local"]), - }, - EnvironmentSelectionCase { - name: "sticky_unset_turn_local_remote", - sticky: None, - turn: Some(&["local", "remote"]), - }, ] { run_environment_selection_case(&mut mcp, &workspace, case).await?; } diff --git a/codex-rs/apply-patch/Cargo.toml b/codex-rs/apply-patch/Cargo.toml index ba4fa5e48e..2584338618 100644 --- a/codex-rs/apply-patch/Cargo.toml +++ b/codex-rs/apply-patch/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [lib] name = "codex_apply_patch" path = "src/lib.rs" +doctest = false [[bin]] name = "apply_patch" diff --git a/codex-rs/apply-patch/src/invocation.rs b/codex-rs/apply-patch/src/invocation.rs index 075c94c60c..1aa429e0d3 100644 --- a/codex-rs/apply-patch/src/invocation.rs +++ b/codex-rs/apply-patch/src/invocation.rs @@ -151,77 +151,87 @@ pub async fn maybe_parse_apply_patch_verified( } match maybe_parse_apply_patch(argv) { - MaybeApplyPatch::Body(ApplyPatchArgs { - patch, - hunks, - workdir, - }) => { - let effective_cwd = workdir - .as_ref() - .map(|dir| cwd.join(Path::new(dir))) - .unwrap_or_else(|| cwd.clone()); - let mut changes = HashMap::new(); - for hunk in hunks { - let path = hunk.resolve_path(&effective_cwd); - match hunk { - Hunk::AddFile { contents, .. } => { - changes.insert( - path.into_path_buf(), - ApplyPatchFileChange::Add { content: contents }, - ); - } - Hunk::DeleteFile { .. } => { - let content = match fs.read_file_text(&path, sandbox).await { - Ok(content) => content, - Err(e) => { - return MaybeApplyPatchVerified::CorrectnessError( - ApplyPatchError::IoError(IoError { - context: format!("Failed to read {}", path.display()), - source: e, - }), - ); - } - }; - changes.insert( - path.into_path_buf(), - ApplyPatchFileChange::Delete { content }, - ); - } - Hunk::UpdateFile { - move_path, chunks, .. - } => { - let ApplyPatchFileUpdate { - unified_diff, - content: contents, - } = match unified_diff_from_chunks(&path, &chunks, fs, sandbox).await { - Ok(diff) => diff, - Err(e) => { - return MaybeApplyPatchVerified::CorrectnessError(e); - } - }; - changes.insert( - path.into_path_buf(), - ApplyPatchFileChange::Update { - unified_diff, - move_path: move_path.map(|p| effective_cwd.join(p).into_path_buf()), - new_content: contents, - }, - ); - } - } - } - MaybeApplyPatchVerified::Body(ApplyPatchAction { - changes, - patch, - cwd: effective_cwd, - }) - } + MaybeApplyPatch::Body(args) => verify_apply_patch_args(args, cwd, fs, sandbox).await, MaybeApplyPatch::ShellParseError(e) => MaybeApplyPatchVerified::ShellParseError(e), MaybeApplyPatch::PatchParseError(e) => MaybeApplyPatchVerified::CorrectnessError(e.into()), MaybeApplyPatch::NotApplyPatch => MaybeApplyPatchVerified::NotApplyPatch, } } +pub async fn verify_apply_patch_args( + args: ApplyPatchArgs, + cwd: &AbsolutePathBuf, + fs: &dyn ExecutorFileSystem, + sandbox: Option<&codex_exec_server::FileSystemSandboxContext>, +) -> MaybeApplyPatchVerified { + let ApplyPatchArgs { + patch, + hunks, + workdir, + .. + } = args; + let effective_cwd = workdir + .as_ref() + .map(|dir| cwd.join(Path::new(dir))) + .unwrap_or_else(|| cwd.clone()); + let mut changes = HashMap::new(); + for hunk in hunks { + let path = hunk.resolve_path(&effective_cwd); + match hunk { + Hunk::AddFile { contents, .. } => { + changes.insert( + path.into_path_buf(), + ApplyPatchFileChange::Add { content: contents }, + ); + } + Hunk::DeleteFile { .. } => { + let content = match fs.read_file_text(&path, sandbox).await { + Ok(content) => content, + Err(e) => { + return MaybeApplyPatchVerified::CorrectnessError( + ApplyPatchError::IoError(IoError { + context: format!("Failed to read {}", path.display()), + source: e, + }), + ); + } + }; + changes.insert( + path.into_path_buf(), + ApplyPatchFileChange::Delete { content }, + ); + } + Hunk::UpdateFile { + move_path, chunks, .. + } => { + let ApplyPatchFileUpdate { + unified_diff, + content: contents, + .. + } = match unified_diff_from_chunks(&path, &chunks, fs, sandbox).await { + Ok(diff) => diff, + Err(e) => { + return MaybeApplyPatchVerified::CorrectnessError(e); + } + }; + changes.insert( + path.into_path_buf(), + ApplyPatchFileChange::Update { + unified_diff, + move_path: move_path.map(|p| effective_cwd.join(p).into_path_buf()), + new_content: contents, + }, + ); + } + } + } + MaybeApplyPatchVerified::Body(ApplyPatchAction { + changes, + patch, + cwd: effective_cwd, + }) +} + /// Extract the heredoc body (and optional `cd` workdir) from a `bash -lc` script /// that invokes the apply_patch tool using a heredoc. /// @@ -707,6 +717,7 @@ PATCH"#, "#; let expected = ApplyPatchFileUpdate { unified_diff: expected_diff.to_string(), + original_content: "foo\nbar\nbaz\n".to_string(), content: "foo\nbar\nBAZ\n".to_string(), }; assert_eq!(expected, diff); @@ -745,6 +756,7 @@ PATCH"#, "#; let expected = ApplyPatchFileUpdate { unified_diff: expected_diff.to_string(), + original_content: "foo\nbar\nbaz\n".to_string(), content: "foo\nbar\nbaz\nquux\n".to_string(), }; assert_eq!(expected, diff); @@ -839,9 +851,10 @@ PATCH"#, assert_eq!(action.cwd.as_path(), worktree_dir.as_path()); + let source_path = worktree_dir.join(source_name); let change = action .changes() - .get(&worktree_dir.join(source_name)) + .get(source_path.as_path()) .expect("source file change present"); match change { @@ -854,4 +867,60 @@ PATCH"#, other => panic!("expected update change, got {other:?}"), } } + + #[tokio::test] + async fn test_unreadable_destinations_still_verify() { + let session_dir = tempdir().unwrap(); + fs::write(session_dir.path().join("binary.dat"), [0xff, 0xfe, 0xfd]).unwrap(); + let cwd = AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(); + let add_argv = vec![ + "apply_patch".to_string(), + "*** Begin Patch\n*** Add File: binary.dat\n+text\n*** End Patch".to_string(), + ]; + fs::write(session_dir.path().join("source.txt"), "before\n").unwrap(); + let move_argv = vec![ + "apply_patch".to_string(), + "*** Begin Patch\n*** Update File: source.txt\n*** Move to: binary.dat\n@@\n-before\n+after\n*** End Patch".to_string(), + ]; + + for argv in [add_argv, move_argv] { + let result = maybe_parse_apply_patch_verified( + &argv, + &cwd, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await; + + assert!(matches!(result, MaybeApplyPatchVerified::Body(_))); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn test_delete_symlink_still_verifies() { + use std::os::unix::fs::symlink; + + let session_dir = tempdir().unwrap(); + fs::write(session_dir.path().join("target.txt"), "target\n").unwrap(); + symlink( + session_dir.path().join("target.txt"), + session_dir.path().join("link.txt"), + ) + .unwrap(); + let argv = vec![ + "apply_patch".to_string(), + "*** Begin Patch\n*** Delete File: link.txt\n*** End Patch".to_string(), + ]; + + let result = maybe_parse_apply_patch_verified( + &argv, + &AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(), + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await; + + assert!(matches!(result, MaybeApplyPatchVerified::Body(_))); + } } diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 7a47b1ea48..aa2326ab28 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -26,6 +26,7 @@ pub use streaming_parser::StreamingPatchParser; use thiserror::Error; pub use invocation::maybe_parse_apply_patch_verified; +pub use invocation::verify_apply_patch_args; pub use standalone_executable::main; use crate::invocation::ExtractHeredocError; @@ -97,6 +98,7 @@ pub struct ApplyPatchArgs { pub patch: String, pub hunks: Vec, pub workdir: Option, + pub environment_id: Option, } #[derive(Debug, PartialEq)] @@ -180,6 +182,99 @@ impl ApplyPatchAction { } } +/// Textual file changes that were actually committed while applying a patch. +#[derive(Clone, Debug, PartialEq)] +pub struct AppliedPatchDelta { + changes: Vec, + exact: bool, +} + +impl AppliedPatchDelta { + fn new(changes: Vec, exact: bool) -> Self { + Self { changes, exact } + } + + fn empty() -> Self { + Self::new(Vec::new(), /*exact*/ true) + } + + pub fn changes(&self) -> &[AppliedPatchChange] { + &self.changes + } + + pub fn is_empty(&self) -> bool { + self.changes.is_empty() + } + + pub fn is_exact(&self) -> bool { + self.exact + } + + /// Appends a later committed prefix while preserving the aggregate exactness. + pub fn append(&mut self, other: Self) { + self.changes.extend(other.changes); + self.exact &= other.exact; + } +} + +impl Default for AppliedPatchDelta { + fn default() -> Self { + Self::empty() + } +} + +/// A committed file change, preserved in the order it was applied. +#[derive(Clone, Debug, PartialEq)] +pub struct AppliedPatchChange { + pub path: PathBuf, + pub change: AppliedPatchFileChange, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum AppliedPatchFileChange { + Add { + content: String, + overwritten_content: Option, + }, + Delete { + content: String, + }, + Update { + move_path: Option, + old_content: String, + overwritten_move_content: Option, + new_content: String, + }, +} + +/// A failed patch application together with the textual mutations that were +/// definitely committed before the failure was observed. +#[derive(Debug, Error)] +#[error("{error}")] +pub struct ApplyPatchFailure { + #[source] + error: ApplyPatchError, + delta: AppliedPatchDelta, +} + +impl ApplyPatchFailure { + fn new(error: ApplyPatchError, delta: AppliedPatchDelta) -> Self { + Self { error, delta } + } + + fn without_delta(error: ApplyPatchError) -> Self { + Self::new(error, AppliedPatchDelta::empty()) + } + + pub fn delta(&self) -> &AppliedPatchDelta { + &self.delta + } + + pub fn into_parts(self) -> (ApplyPatchError, AppliedPatchDelta) { + (self.error, self.delta) + } +} + /// Applies the patch and prints the result to stdout/stderr. pub async fn apply_patch( patch: &str, @@ -188,13 +283,15 @@ pub async fn apply_patch( stderr: &mut impl std::io::Write, fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, -) -> Result<(), ApplyPatchError> { +) -> Result { let hunks = match parse_patch(patch) { Ok(source) => source.hunks, Err(e) => { match &e { InvalidPatchError(message) => { - writeln!(stderr, "Invalid patch: {message}").map_err(ApplyPatchError::from)?; + writeln!(stderr, "Invalid patch: {message}") + .map_err(ApplyPatchError::from) + .map_err(ApplyPatchFailure::without_delta)?; } InvalidHunkError { message, @@ -204,16 +301,17 @@ pub async fn apply_patch( stderr, "Invalid patch hunk on line {line_number}: {message}" ) - .map_err(ApplyPatchError::from)?; + .map_err(ApplyPatchError::from) + .map_err(ApplyPatchFailure::without_delta)?; } } - return Err(ApplyPatchError::ParseError(e)); + return Err(ApplyPatchFailure::without_delta( + ApplyPatchError::ParseError(e), + )); } }; - apply_hunks(&hunks, cwd, stdout, stderr, fs, sandbox).await?; - - Ok(()) + apply_hunks(&hunks, cwd, stdout, stderr, fs, sandbox).await } /// Applies hunks and continues to update stdout/stderr @@ -224,24 +322,29 @@ pub async fn apply_hunks( stderr: &mut impl std::io::Write, fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, -) -> Result<(), ApplyPatchError> { - // Delegate to a helper that applies each hunk to the filesystem. - match apply_hunks_to_files(hunks, cwd, fs, sandbox).await { - Ok(affected) => { - print_summary(&affected, stdout).map_err(ApplyPatchError::from)?; - Ok(()) +) -> Result { + let mut delta = AppliedPatchDelta::empty(); + match apply_hunks_to_files(hunks, cwd, fs, sandbox, &mut delta).await { + Ok(affected_paths) => { + print_summary(&affected_paths, stdout).map_err(|error| { + ApplyPatchFailure::new(ApplyPatchError::from(error), delta.clone()) + })?; + Ok(delta) } - Err(err) => { - let msg = err.to_string(); - writeln!(stderr, "{msg}").map_err(ApplyPatchError::from)?; - if let Some(io) = err.downcast_ref::() { - Err(ApplyPatchError::from(io)) + Err(error) => { + let msg = error.to_string(); + writeln!(stderr, "{msg}").map_err(|error| { + ApplyPatchFailure::new(ApplyPatchError::from(error), delta.clone()) + })?; + let error = if let Some(io) = error.downcast_ref::() { + ApplyPatchError::from(io) } else { - Err(ApplyPatchError::IoError(IoError { + ApplyPatchError::IoError(IoError { context: msg, - source: std::io::Error::other(err), - })) - } + source: std::io::Error::other(error), + }) + }; + Err(ApplyPatchFailure::new(error, delta)) } } } @@ -263,6 +366,7 @@ async fn apply_hunks_to_files( cwd: &AbsolutePathBuf, fs: &dyn ExecutorFileSystem, sandbox: Option<&FileSystemSandboxContext>, + delta: &mut AppliedPatchDelta, ) -> anyhow::Result { if hunks.is_empty() { anyhow::bail!("No files were modified."); @@ -271,30 +375,58 @@ async fn apply_hunks_to_files( let mut added: Vec = Vec::new(); let mut modified: Vec = Vec::new(); let mut deleted: Vec = Vec::new(); + // A failed write can still have modified the target before surfacing an + // error (for example by truncating before ENOSPC), so the accumulated + // delta is no longer exact when a write fails. + macro_rules! try_write { + ($result:expr) => { + match $result { + Ok(value) => value, + Err(error) => { + delta.exact = false; + return Err(anyhow::Error::from(error)); + } + } + }; + } + for hunk in hunks { let affected_path = hunk.path().to_path_buf(); let path_abs = hunk.resolve_path(cwd); match hunk { Hunk::AddFile { contents, .. } => { - write_file_with_missing_parent_retry( - fs, - &path_abs, - contents.clone().into_bytes(), - sandbox, - ) - .await?; + let overwritten_content = + read_optional_file_text_for_delta(&path_abs, fs, sandbox, &mut delta.exact) + .await; + try_write!( + write_file_with_missing_parent_retry( + fs, + &path_abs, + contents.clone().into_bytes(), + sandbox, + ) + .await + ); + delta.changes.push(AppliedPatchChange { + path: path_abs.into_path_buf(), + change: AppliedPatchFileChange::Add { + content: contents.clone(), + overwritten_content, + }, + }); added.push(affected_path); } Hunk::DeleteFile { .. } => { - let result: io::Result<()> = async { - let metadata = fs.get_metadata(&path_abs, sandbox).await?; - if metadata.is_directory { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "path is a directory", - )); - } - fs.remove( + note_existing_path_delta_support(&path_abs, fs, sandbox, &mut delta.exact).await; + let deleted_content = fs.read_file_text(&path_abs, sandbox).await.ok(); + if deleted_content.is_none() { + delta.exact = false; + } + ensure_not_directory(&path_abs, fs, sandbox) + .await + .with_context(|| format!("Failed to delete file {}", path_abs.display()))?; + if let Err(error) = fs + .remove( &path_abs, RemoveOptions { recursive: false, @@ -303,34 +435,62 @@ async fn apply_hunks_to_files( sandbox, ) .await + .with_context(|| format!("Failed to delete file {}", path_abs.display())) + { + delta.exact &= remove_failure_was_side_effect_free( + &path_abs, + deleted_content.as_deref(), + fs, + sandbox, + ) + .await; + return Err(error); + } + if let Some(content) = deleted_content { + delta.changes.push(AppliedPatchChange { + path: path_abs.into_path_buf(), + change: AppliedPatchFileChange::Delete { content }, + }); } - .await; - result.with_context(|| format!("Failed to delete file {}", path_abs.display()))?; deleted.push(affected_path); } Hunk::UpdateFile { move_path, chunks, .. } => { - let AppliedPatch { new_contents, .. } = - derive_new_contents_from_chunks(&path_abs, chunks, fs, sandbox).await?; + note_existing_path_delta_support(&path_abs, fs, sandbox, &mut delta.exact).await; + let AppliedPatch { + original_contents, + new_contents, + } = derive_new_contents_from_chunks(&path_abs, chunks, fs, sandbox).await?; if let Some(dest) = move_path { let dest_abs = AbsolutePathBuf::resolve_path_against_base(dest, cwd); - write_file_with_missing_parent_retry( - fs, - &dest_abs, - new_contents.into_bytes(), - sandbox, - ) - .await?; - let result: io::Result<()> = async { - let metadata = fs.get_metadata(&path_abs, sandbox).await?; - if metadata.is_directory { - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "path is a directory", - )); - } - fs.remove( + let overwritten_move_content = + read_optional_file_text_for_delta(&dest_abs, fs, sandbox, &mut delta.exact) + .await; + try_write!( + write_file_with_missing_parent_retry( + fs, + &dest_abs, + new_contents.clone().into_bytes(), + sandbox, + ) + .await + ); + let dest_write_change_index = delta.changes.len(); + delta.changes.push(AppliedPatchChange { + path: dest_abs.to_path_buf(), + change: AppliedPatchFileChange::Add { + content: new_contents.clone(), + overwritten_content: overwritten_move_content.clone(), + }, + }); + ensure_not_directory(&path_abs, fs, sandbox) + .await + .with_context(|| { + format!("Failed to remove original {}", path_abs.display()) + })?; + if let Err(error) = fs + .remove( &path_abs, RemoveOptions { recursive: false, @@ -339,16 +499,47 @@ async fn apply_hunks_to_files( sandbox, ) .await + .with_context(|| { + format!("Failed to remove original {}", path_abs.display()) + }) + { + delta.exact &= remove_failure_was_side_effect_free( + &path_abs, + Some(&original_contents), + fs, + sandbox, + ) + .await; + return Err(error); } - .await; - result.with_context(|| { - format!("Failed to remove original {}", path_abs.display()) - })?; + delta.changes[dest_write_change_index] = AppliedPatchChange { + path: path_abs.into_path_buf(), + change: AppliedPatchFileChange::Update { + move_path: Some(dest_abs.into_path_buf()), + old_content: original_contents, + overwritten_move_content, + new_content: new_contents, + }, + }; modified.push(affected_path); } else { - fs.write_file(&path_abs, new_contents.into_bytes(), sandbox) - .await - .with_context(|| format!("Failed to write file {}", path_abs.display()))?; + try_write!( + fs.write_file(&path_abs, new_contents.clone().into_bytes(), sandbox) + .await + .with_context(|| format!( + "Failed to write file {}", + path_abs.display() + )) + ); + delta.changes.push(AppliedPatchChange { + path: path_abs.into_path_buf(), + change: AppliedPatchFileChange::Update { + move_path: None, + old_content: original_contents, + overwritten_move_content: None, + new_content: new_contents, + }, + }); modified.push(affected_path); } } @@ -361,6 +552,67 @@ async fn apply_hunks_to_files( }) } +async fn ensure_not_directory( + path: &AbsolutePathBuf, + fs: &dyn ExecutorFileSystem, + sandbox: Option<&FileSystemSandboxContext>, +) -> io::Result<()> { + let metadata = fs.get_metadata(path, sandbox).await?; + if metadata.is_directory { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "path is a directory", + )); + } + Ok(()) +} + +async fn remove_failure_was_side_effect_free( + path: &AbsolutePathBuf, + expected_content: Option<&str>, + fs: &dyn ExecutorFileSystem, + sandbox: Option<&FileSystemSandboxContext>, +) -> bool { + match expected_content { + Some(expected_content) => fs + .read_file_text(path, sandbox) + .await + .is_ok_and(|content| content == expected_content), + None => false, + } +} + +async fn read_optional_file_text_for_delta( + path: &AbsolutePathBuf, + fs: &dyn ExecutorFileSystem, + sandbox: Option<&FileSystemSandboxContext>, + exact: &mut bool, +) -> Option { + note_existing_path_delta_support(path, fs, sandbox, exact).await; + match fs.read_file_text(path, sandbox).await { + Ok(content) => Some(content), + Err(source) if source.kind() == io::ErrorKind::NotFound => None, + Err(_) => { + *exact = false; + None + } + } +} + +async fn note_existing_path_delta_support( + path: &AbsolutePathBuf, + fs: &dyn ExecutorFileSystem, + sandbox: Option<&FileSystemSandboxContext>, + exact: &mut bool, +) { + match fs.get_metadata(path, sandbox).await { + Ok(metadata) if metadata.is_file && !metadata.is_symlink => {} + Ok(_) => *exact = false, + Err(source) if source.kind() == io::ErrorKind::NotFound => {} + Err(_) => *exact = false, + } +} + async fn write_file_with_missing_parent_retry( fs: &dyn ExecutorFileSystem, path_abs: &AbsolutePathBuf, @@ -561,6 +813,7 @@ fn apply_replacements( #[derive(Debug, Eq, PartialEq)] pub struct ApplyPatchFileUpdate { unified_diff: String, + original_content: String, content: String, } @@ -588,6 +841,7 @@ pub async fn unified_diff_from_chunks_with_context( let unified_diff = text_diff.unified_diff().context_radius(context).to_string(); Ok(ApplyPatchFileUpdate { unified_diff, + original_content: original_contents, content: new_contents, }) } @@ -841,6 +1095,61 @@ mod tests { assert_eq!(contents, "line2\n"); } + #[cfg(unix)] + #[tokio::test] + async fn test_failed_move_returns_committed_destination_delta() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempdir().unwrap(); + let source_dir = dir.path().join("locked"); + let dest_dir = dir.path().join("out"); + fs::create_dir(&source_dir).unwrap(); + fs::create_dir(&dest_dir).unwrap(); + let src = source_dir.join("src.txt"); + let dest = dest_dir.join("dst.txt"); + fs::write(&src, "line\n").unwrap(); + fs::set_permissions(&source_dir, fs::Permissions::from_mode(0o555)).unwrap(); + + let patch = wrap_patch( + "*** Update File: locked/src.txt\n*** Move to: out/dst.txt\n@@\n-line\n+line2", + ); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let failure = apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await + .expect_err("source removal should fail after destination write"); + + fs::set_permissions(&source_dir, fs::Permissions::from_mode(0o755)).unwrap(); + + assert!( + String::from_utf8(stderr) + .unwrap() + .contains(&format!("Failed to remove original {}", src.display())) + ); + assert_eq!( + failure.delta(), + &AppliedPatchDelta::new( + vec![AppliedPatchChange { + path: dest.clone(), + change: AppliedPatchFileChange::Add { + content: "line2\n".to_string(), + overwritten_content: None, + }, + }], + /*exact*/ true, + ) + ); + assert_eq!(fs::read_to_string(src).unwrap(), "line\n"); + assert_eq!(fs::read_to_string(dest).unwrap(), "line2\n"); + } + /// Verify that a single `Update File` hunk with multiple change chunks can update different /// parts of a file and that the file is listed only once in the summary. #[tokio::test] @@ -1082,6 +1391,7 @@ mod tests { "#; let expected = ApplyPatchFileUpdate { unified_diff: expected_diff.to_string(), + original_content: "foo\nbar\nbaz\nqux\n".to_string(), content: "foo\nBAR\nbaz\nQUX\n".to_string(), }; assert_eq!(expected, diff); @@ -1122,6 +1432,7 @@ mod tests { "#; let expected = ApplyPatchFileUpdate { unified_diff: expected_diff.to_string(), + original_content: "foo\nbar\nbaz\n".to_string(), content: "FOO\nbar\nbaz\n".to_string(), }; assert_eq!(expected, diff); @@ -1163,6 +1474,7 @@ mod tests { "#; let expected = ApplyPatchFileUpdate { unified_diff: expected_diff.to_string(), + original_content: "foo\nbar\nbaz\n".to_string(), content: "foo\nbar\nBAZ\n".to_string(), }; assert_eq!(expected, diff); @@ -1201,6 +1513,7 @@ mod tests { "#; let expected = ApplyPatchFileUpdate { unified_diff: expected_diff.to_string(), + original_content: "foo\nbar\nbaz\n".to_string(), content: "foo\nbar\nbaz\nquux\n".to_string(), }; assert_eq!(expected, diff); @@ -1260,6 +1573,7 @@ mod tests { let expected = ApplyPatchFileUpdate { unified_diff: expected_diff.to_string(), + original_content: "a\nb\nc\nd\ne\nf\n".to_string(), content: "a\nB\nc\nd\nE\nf\ng\n".to_string(), }; @@ -1291,19 +1605,17 @@ g ); } + #[cfg(unix)] #[tokio::test] async fn test_apply_patch_fails_on_write_error() { - let dir = tempdir().unwrap(); - let path = dir.path().join("readonly.txt"); - fs::write(&path, "before\n").unwrap(); - let mut perms = fs::metadata(&path).unwrap().permissions(); - perms.set_readonly(true); - fs::set_permissions(&path, perms).unwrap(); + use std::os::unix::fs::PermissionsExt; - let patch = wrap_patch(&format!( - "*** Update File: {}\n@@\n-before\n+after\n*** End Patch", - path.display() - )); + let dir = tempdir().unwrap(); + let locked_dir = dir.path().join("locked"); + fs::create_dir(&locked_dir).unwrap(); + fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o555)).unwrap(); + + let patch = wrap_patch("*** Add File: locked/new.txt\n+after"); let mut stdout = Vec::new(); let mut stderr = Vec::new(); @@ -1316,6 +1628,65 @@ g /*sandbox*/ None, ) .await; - assert!(result.is_err()); + let failure = result.expect_err("write should fail"); + + fs::set_permissions(&locked_dir, fs::Permissions::from_mode(0o755)).unwrap(); + + assert!(!failure.delta().is_exact()); + } + + #[tokio::test] + async fn test_unreadable_destinations_return_inexact_delta() { + let dir = tempdir().unwrap(); + let path = dir.path().join("binary.dat"); + fs::write(dir.path().join("source.txt"), "before\n").unwrap(); + let cwd = AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(); + + for patch in [ + wrap_patch("*** Add File: binary.dat\n+text"), + wrap_patch("*** Update File: source.txt\n*** Move to: binary.dat\n@@\n-before\n+after"), + ] { + fs::write(&path, [0xff, 0xfe, 0xfd]).unwrap(); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let delta = apply_patch( + &patch, + &cwd, + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await + .unwrap(); + + assert!(!delta.is_exact()); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn test_delete_symlink_returns_inexact_delta() { + use std::os::unix::fs::symlink; + + let dir = tempdir().unwrap(); + fs::write(dir.path().join("target.txt"), "target\n").unwrap(); + symlink(dir.path().join("target.txt"), dir.path().join("link.txt")).unwrap(); + let patch = wrap_patch("*** Delete File: link.txt"); + + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let delta = apply_patch( + &patch, + &AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(), + &mut stdout, + &mut stderr, + LOCAL_FS.as_ref(), + /*sandbox*/ None, + ) + .await + .unwrap(); + + assert!(!delta.is_exact()); } } diff --git a/codex-rs/apply-patch/src/parser.rs b/codex-rs/apply-patch/src/parser.rs index 5403055272..b3b2337c39 100644 --- a/codex-rs/apply-patch/src/parser.rs +++ b/codex-rs/apply-patch/src/parser.rs @@ -3,8 +3,9 @@ //! //! The official Lark grammar for the apply-patch format is: //! -//! start: begin_patch hunk+ end_patch +//! start: begin_patch environment_id? hunk+ end_patch //! begin_patch: "*** Begin Patch" LF +//! environment_id: "*** Environment ID: " filename LF //! end_patch: "*** End Patch" LF? //! //! hunk: add_hunk | delete_hunk | update_hunk @@ -32,6 +33,7 @@ use std::path::PathBuf; use thiserror::Error; pub(crate) const BEGIN_PATCH_MARKER: &str = "*** Begin Patch"; +pub(crate) const ENVIRONMENT_ID_MARKER: &str = "*** Environment ID: "; pub(crate) const END_PATCH_MARKER: &str = "*** End Patch"; pub(crate) const ADD_FILE_MARKER: &str = "*** Add File: "; pub(crate) const DELETE_FILE_MARKER: &str = "*** Delete File: "; @@ -178,9 +180,9 @@ fn parse_patch_text(patch: &str, mode: ParseMode) -> Result check_patch_boundaries_lenient(&lines)?, }; + let (environment_id, mut remaining_lines, mut line_number) = + parse_environment_id_preamble(hunk_lines)?; let mut hunks: Vec = Vec::new(); - let mut remaining_lines = hunk_lines; - let mut line_number = 2; while !remaining_lines.is_empty() { let (hunk, hunk_lines) = parse_one_hunk(remaining_lines, line_number)?; hunks.push(hunk); @@ -192,9 +194,28 @@ fn parse_patch_text(patch: &str, mode: ParseMode) -> Result( + hunk_lines: &'a [&'a str], +) -> Result<(Option, &'a [&'a str], usize), ParseError> { + let Some(first_line) = hunk_lines.first() else { + return Ok((None, hunk_lines, 2)); + }; + let Some(environment_id) = first_line.trim_start().strip_prefix(ENVIRONMENT_ID_MARKER) else { + return Ok((None, hunk_lines, 2)); + }; + let environment_id = environment_id.trim(); + if environment_id.is_empty() { + return Err(InvalidPatchError( + "apply_patch environment_id cannot be empty".to_string(), + )); + } + Ok((Some(environment_id.to_string()), &hunk_lines[1..], 3)) +} + /// Checks the start and end lines of the patch text for `apply_patch`, /// returning an error if they do not match the expected markers. fn check_patch_boundaries_strict<'a>( @@ -837,6 +858,7 @@ fn test_parse_patch_lenient() { hunks: expected_patch.clone(), patch: patch_text.to_string(), workdir: None, + environment_id: None, }) ); @@ -851,6 +873,7 @@ fn test_parse_patch_lenient() { hunks: expected_patch.clone(), patch: patch_text.to_string(), workdir: None, + environment_id: None, }) ); @@ -865,6 +888,7 @@ fn test_parse_patch_lenient() { hunks: expected_patch, patch: patch_text.to_string(), workdir: None, + environment_id: None, }) ); @@ -891,3 +915,40 @@ fn test_parse_patch_lenient() { )) ); } + +#[test] +fn test_parse_patch_environment_id_preamble() { + assert_eq!( + parse_patch_text( + "*** Begin Patch\n\ + *** Environment ID: remote\n\ + *** Add File: hello.txt\n\ + +hello\n\ + *** End Patch", + ParseMode::Strict + ), + Ok(ApplyPatchArgs { + hunks: vec![AddFile { + path: PathBuf::from("hello.txt"), + contents: "hello\n".to_string(), + }], + patch: "*** Begin Patch\n*** Environment ID: remote\n*** Add File: hello.txt\n+hello\n*** End Patch".to_string(), + workdir: None, + environment_id: Some("remote".to_string()), + }) + ); + + assert_eq!( + parse_patch_text( + "*** Begin Patch\n\ + *** Environment ID: \n\ + *** Add File: hello.txt\n\ + +hello\n\ + *** End Patch", + ParseMode::Strict + ), + Err(InvalidPatchError( + "apply_patch environment_id cannot be empty".to_string() + )) + ); +} diff --git a/codex-rs/apply-patch/src/standalone_executable.rs b/codex-rs/apply-patch/src/standalone_executable.rs index 093bda543b..45ca0d0619 100644 --- a/codex-rs/apply-patch/src/standalone_executable.rs +++ b/codex-rs/apply-patch/src/standalone_executable.rs @@ -73,7 +73,7 @@ pub fn run_main() -> i32 { codex_exec_server::LOCAL_FS.as_ref(), /*sandbox*/ None, )) { - Ok(()) => { + Ok(_) => { // Flush to ensure output ordering when used in pipelines. let _ = stdout.flush(); 0 diff --git a/codex-rs/apply-patch/src/streaming_parser.rs b/codex-rs/apply-patch/src/streaming_parser.rs index 4acfad6720..86084af0b8 100644 --- a/codex-rs/apply-patch/src/streaming_parser.rs +++ b/codex-rs/apply-patch/src/streaming_parser.rs @@ -16,6 +16,8 @@ use crate::parser::UpdateFileChunk; use Hunk::*; use ParseError::*; +const ENVIRONMENT_ID_MARKER: &str = "*** Environment ID: "; + #[derive(Debug, Default, Clone)] pub struct StreamingPatchParser { line_buffer: String, @@ -29,7 +31,7 @@ struct StreamingParserState { hunks: Vec, } -#[derive(Debug, Default, Clone)] +#[derive(Debug, Default, Clone, Copy)] enum StreamingParserMode { #[default] NotStarted, @@ -43,6 +45,13 @@ enum StreamingParserMode { } impl StreamingPatchParser { + // The live streaming parser only needs to keep the patch preview flowing. + // Environment selection and validation happen on the final tool invocation, + // so here we just tolerate and skip the optional preamble line. + fn is_environment_id_preamble_line(&self, line: &str) -> bool { + line.starts_with(ENVIRONMENT_ID_MARKER) + } + fn ensure_update_hunk_is_not_empty(&self, line: &str) -> Result<(), ParseError> { if let Some(UpdateFile { path, chunks, .. }) = self.state.hunks.last() { if chunks.is_empty() @@ -150,7 +159,7 @@ impl StreamingPatchParser { fn process_line(&mut self, line: &str) -> Result<(), ParseError> { let trimmed = line.trim(); - match self.state.mode.clone() { + match self.state.mode { StreamingParserMode::NotStarted => { if trimmed == BEGIN_PATCH_MARKER { self.state.mode = StreamingParserMode::StartedPatch; @@ -161,6 +170,9 @@ impl StreamingPatchParser { )) } StreamingParserMode::StartedPatch => { + if self.is_environment_id_preamble_line(line) { + return Ok(()); + } if self.handle_hunk_headers_and_end_patch(trimmed)? { return Ok(()); } @@ -431,6 +443,32 @@ mod tests { ); } + #[test] + fn test_streaming_patch_parser_environment_id_mode() { + let patch = "\ +*** Begin Patch +*** Environment ID: remote +*** Add File: src/hello.txt ++hello +*** End Patch +"; + + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta(patch), + Ok(vec![AddFile { + path: PathBuf::from("src/hello.txt"), + contents: "hello\n".to_string(), + }]) + ); + + let mut parser = StreamingPatchParser::default(); + assert_eq!( + parser.push_delta("*** Begin Patch\n*** Environment ID: \n"), + Ok(vec![]) + ); + } + #[test] fn test_streaming_patch_parser_large_patch_split_by_character() { let patch = "\ diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml index 8da0fcbd0b..7ee21a770e 100644 --- a/codex-rs/arg0/Cargo.toml +++ b/codex-rs/arg0/Cargo.toml @@ -7,6 +7,7 @@ license.workspace = true [lib] name = "codex_arg0" path = "src/lib.rs" +doctest = false [lints] workspace = true diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 75fefce5cc..2f6ae4653c 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -122,7 +122,7 @@ pub fn arg0_dispatch() -> Option { codex_exec_server::LOCAL_FS.as_ref(), /*sandbox*/ None, )) { - Ok(()) => 0, + Ok(_) => 0, Err(_) => 1, } } diff --git a/codex-rs/async-utils/Cargo.toml b/codex-rs/async-utils/Cargo.toml index 891af17a5f..9f81ff818e 100644 --- a/codex-rs/async-utils/Cargo.toml +++ b/codex-rs/async-utils/Cargo.toml @@ -14,3 +14,6 @@ tokio-util.workspace = true [dev-dependencies] pretty_assertions.workspace = true + +[lib] +doctest = false diff --git a/codex-rs/aws-auth/Cargo.toml b/codex-rs/aws-auth/Cargo.toml index 9e49f7bbe5..6bb5a69ae9 100644 --- a/codex-rs/aws-auth/Cargo.toml +++ b/codex-rs/aws-auth/Cargo.toml @@ -13,7 +13,7 @@ path = "src/lib.rs" workspace = true [dependencies] -aws-config = { workspace = true } +aws-config = { workspace = true, features = ["credentials-login"] } aws-credential-types = { workspace = true } aws-sigv4 = { workspace = true } aws-types = { workspace = true } diff --git a/codex-rs/backend-client/Cargo.toml b/codex-rs/backend-client/Cargo.toml index d2e374ae2a..f7b0c8b0f5 100644 --- a/codex-rs/backend-client/Cargo.toml +++ b/codex-rs/backend-client/Cargo.toml @@ -7,6 +7,7 @@ publish = false [lib] path = "src/lib.rs" +doctest = false [lints] workspace = true diff --git a/codex-rs/bwrap/BUILD.bazel b/codex-rs/bwrap/BUILD.bazel new file mode 100644 index 0000000000..3d0b89b966 --- /dev/null +++ b/codex-rs/bwrap/BUILD.bazel @@ -0,0 +1,35 @@ +load("@rules_cc//cc:defs.bzl", "cc_library") +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "bwrap", + crate_name = "codex_bwrap", + # Bazel wires vendored bubblewrap + libcap via :bwrap-ffi below and sets + # bwrap_available explicitly, so we skip Cargo's build.rs in Bazel builds. + build_script_enabled = False, + deps_extra = select({ + "@platforms//os:linux": [":bwrap-ffi"], + "//conditions:default": [], + }), + rustc_flags_extra = select({ + "@platforms//os:linux": ["--cfg=bwrap_available"], + "//conditions:default": [], + }), +) + +cc_library( + name = "bwrap-ffi", + srcs = ["//codex-rs/vendor:bubblewrap_c_sources"], + hdrs = [ + "config.h", + "//codex-rs/vendor:bubblewrap_headers", + ], + copts = [ + "-D_GNU_SOURCE", + "-Dmain=bwrap_main", + ], + includes = ["."], + deps = ["@libcap//:libcap"], + target_compatible_with = ["@platforms//os:linux"], + visibility = ["//visibility:private"], +) diff --git a/codex-rs/bwrap/Cargo.toml b/codex-rs/bwrap/Cargo.toml new file mode 100644 index 0000000000..ed7010c8fd --- /dev/null +++ b/codex-rs/bwrap/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "codex-bwrap" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "bwrap" +path = "src/main.rs" + +[lints] +workspace = true + +[target.'cfg(target_os = "linux")'.dependencies] +libc = { workspace = true } + +[build-dependencies] +cc = "1" +pkg-config = "0.3" diff --git a/codex-rs/bwrap/build.rs b/codex-rs/bwrap/build.rs new file mode 100644 index 0000000000..d9d87932b2 --- /dev/null +++ b/codex-rs/bwrap/build.rs @@ -0,0 +1,106 @@ +use std::env; +use std::path::Path; +use std::path::PathBuf; + +fn main() { + println!("cargo:rustc-check-cfg=cfg(bwrap_available)"); + println!("cargo:rerun-if-env-changed=CODEX_BWRAP_SOURCE_DIR"); + println!("cargo:rerun-if-env-changed=PKG_CONFIG_ALLOW_CROSS"); + println!("cargo:rerun-if-env-changed=PKG_CONFIG_PATH"); + println!("cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR"); + println!("cargo:rerun-if-env-changed=CODEX_SKIP_BWRAP_BUILD"); + + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap_or_default()); + let vendor_dir = manifest_dir.join("../vendor/bubblewrap"); + for source in ["bubblewrap.c", "bind-mount.c", "network.c", "utils.c"] { + println!( + "cargo:rerun-if-changed={}", + vendor_dir.join(source).display() + ); + } + + let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + if target_os != "linux" || env::var_os("CODEX_SKIP_BWRAP_BUILD").is_some() { + return; + } + + if let Err(err) = try_build_bwrap() { + panic!("failed to compile bubblewrap for Linux target: {err}"); + } +} + +fn try_build_bwrap() -> Result<(), String> { + let manifest_dir = + PathBuf::from(env::var("CARGO_MANIFEST_DIR").map_err(|err| err.to_string())?); + let out_dir = PathBuf::from(env::var("OUT_DIR").map_err(|err| err.to_string())?); + let src_dir = resolve_bwrap_source_dir(&manifest_dir)?; + let libcap = pkg_config::Config::new() + .cargo_metadata(false) + .probe("libcap") + .map_err(|err| format!("libcap not available via pkg-config: {err}"))?; + + let config_h = out_dir.join("config.h"); + std::fs::write( + &config_h, + r#"#pragma once +#define PACKAGE_STRING "bubblewrap built for Codex" +"#, + ) + .map_err(|err| format!("failed to write {}: {err}", config_h.display()))?; + + let mut build = cc::Build::new(); + build + .file(src_dir.join("bubblewrap.c")) + .file(src_dir.join("bind-mount.c")) + .file(src_dir.join("network.c")) + .file(src_dir.join("utils.c")) + .include(&out_dir) + .include(&src_dir) + .define("_GNU_SOURCE", None) + // Rename `main` so the Rust wrapper can expose the Cargo-built binary. + .define("main", Some("bwrap_main")); + for include_path in libcap.include_paths { + // Use -idirafter so target sysroot headers win (musl cross builds), + // while still allowing libcap headers from the host toolchain. + build.flag(format!("-idirafter{}", include_path.display())); + } + + build.compile("standalone_bwrap"); + for link_path in libcap.link_paths { + println!("cargo:rustc-link-search=native={}", link_path.display()); + } + for lib in libcap.libs { + println!("cargo:rustc-link-lib={lib}"); + } + println!("cargo:rustc-cfg=bwrap_available"); + Ok(()) +} + +/// Resolve the bubblewrap source directory used for build-time compilation. +/// +/// Priority: +/// 1. `CODEX_BWRAP_SOURCE_DIR` points at an existing bubblewrap checkout. +/// 2. The vendored bubblewrap tree under `codex-rs/vendor/bubblewrap`. +fn resolve_bwrap_source_dir(manifest_dir: &Path) -> Result { + if let Ok(path) = env::var("CODEX_BWRAP_SOURCE_DIR") { + let src_dir = PathBuf::from(path); + if src_dir.exists() { + return Ok(src_dir); + } + return Err(format!( + "CODEX_BWRAP_SOURCE_DIR was set but does not exist: {}", + src_dir.display() + )); + } + + let vendor_dir = manifest_dir.join("../vendor/bubblewrap"); + if vendor_dir.exists() { + return Ok(vendor_dir); + } + + Err(format!( + "expected vendored bubblewrap at {}, but it was not found.\n\ +Set CODEX_BWRAP_SOURCE_DIR to an existing checkout or vendor bubblewrap under codex-rs/vendor.", + vendor_dir.display() + )) +} diff --git a/codex-rs/bwrap/config.h b/codex-rs/bwrap/config.h new file mode 100644 index 0000000000..f73932a0f8 --- /dev/null +++ b/codex-rs/bwrap/config.h @@ -0,0 +1 @@ +#define PACKAGE_STRING "bubblewrap built for Codex" diff --git a/codex-rs/bwrap/src/main.rs b/codex-rs/bwrap/src/main.rs new file mode 100644 index 0000000000..09c624aa9e --- /dev/null +++ b/codex-rs/bwrap/src/main.rs @@ -0,0 +1,45 @@ +#[cfg(all(target_os = "linux", bwrap_available))] +fn main() { + use std::ffi::CStr; + use std::ffi::CString; + use std::os::raw::c_char; + use std::os::unix::ffi::OsStrExt; + + unsafe extern "C" { + fn bwrap_main(argc: libc::c_int, argv: *const *const c_char) -> libc::c_int; + } + + let cstrings = std::env::args_os() + .map(|arg| { + CString::new(arg.as_os_str().as_bytes()) + .unwrap_or_else(|err| panic!("failed to convert argv to CString: {err}")) + }) + .collect::>(); + let mut argv_ptrs = cstrings + .iter() + .map(CString::as_c_str) + .map(CStr::as_ptr) + .collect::>(); + argv_ptrs.push(std::ptr::null()); + + // SAFETY: We provide a null-terminated argv vector whose pointers remain + // valid for the duration of the call. + let exit_code = unsafe { bwrap_main(cstrings.len() as libc::c_int, argv_ptrs.as_ptr()) }; + std::process::exit(exit_code); +} + +#[cfg(all(target_os = "linux", not(bwrap_available)))] +fn main() { + panic!( + r#"bubblewrap is not available in this build. +Notes: +- ensure the target OS is Linux +- libcap headers must be available via pkg-config +- bubblewrap sources expected at codex-rs/vendor/bubblewrap (default)"# + ); +} + +#[cfg(not(target_os = "linux"))] +fn main() { + panic!("bwrap is only supported on Linux"); +} diff --git a/codex-rs/chatgpt/Cargo.toml b/codex-rs/chatgpt/Cargo.toml index 62cb56a022..6b0e010964 100644 --- a/codex-rs/chatgpt/Cargo.toml +++ b/codex-rs/chatgpt/Cargo.toml @@ -27,3 +27,6 @@ codex-utils-cargo-bin = { workspace = true } pretty_assertions = { workspace = true } serde_json = { workspace = true } tempfile = { workspace = true } + +[lib] +doctest = false diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index cbeb4fd1b7..2e54192e85 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -4,7 +4,8 @@ use std::time::Duration; use crate::chatgpt_client::chatgpt_get_request_with_timeout; use codex_app_server_protocol::AppInfo; -use codex_connectors::AllConnectorsCacheKey; +use codex_connectors::ConnectorDirectoryCacheContext; +use codex_connectors::ConnectorDirectoryCacheKey; use codex_connectors::DirectoryListResponse; use codex_connectors::filter::filter_disallowed_connectors; use codex_connectors::merge::merge_connectors; @@ -75,8 +76,8 @@ pub async fn list_cached_all_connectors(config: &Config) -> Option> } let auth = connector_auth(config).await.ok()?; - let cache_key = all_connectors_cache_key(config, &auth); - let connectors = codex_connectors::cached_all_connectors(&cache_key)?; + let cache_context = connector_directory_cache_context(config, &auth); + let connectors = codex_connectors::cached_directory_connectors(&cache_context)?; let connectors = merge_plugin_connectors( connectors, plugin_apps_for_config(config) @@ -98,9 +99,9 @@ pub async fn list_all_connectors_with_options( return Ok(Vec::new()); } let auth = connector_auth(config).await?; - let cache_key = all_connectors_cache_key(config, &auth); + let cache_context = connector_directory_cache_context(config, &auth); let connectors = codex_connectors::list_all_connectors_with_options( - cache_key, + cache_context, auth.is_workspace_account(), force_refetch, |path| async move { @@ -126,12 +127,18 @@ pub async fn list_all_connectors_with_options( )) } -fn all_connectors_cache_key(config: &Config, auth: &CodexAuth) -> AllConnectorsCacheKey { - AllConnectorsCacheKey::new( - config.chatgpt_base_url.clone(), - auth.get_account_id(), - auth.get_chatgpt_user_id(), - auth.is_workspace_account(), +fn connector_directory_cache_context( + config: &Config, + auth: &CodexAuth, +) -> ConnectorDirectoryCacheContext { + ConnectorDirectoryCacheContext::new( + config.codex_home.to_path_buf(), + ConnectorDirectoryCacheKey::new( + config.chatgpt_base_url.clone(), + auth.get_account_id(), + auth.get_chatgpt_user_id(), + auth.is_workspace_account(), + ), ) } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index cdee241b42..120d2e497a 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -12,6 +12,7 @@ path = "src/main.rs" [lib] name = "codex_cli" path = "src/lib.rs" +doctest = false [lints] workspace = true @@ -21,6 +22,7 @@ anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } clap_complete = { workspace = true } codex-app-server = { workspace = true } +codex-app-server-daemon = { workspace = true } codex-app-server-protocol = { workspace = true } codex-app-server-test-client = { workspace = true } codex-arg0 = { workspace = true } diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index e9bc6a046e..2722f69849 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -370,6 +370,11 @@ async fn run_command_under_windows_session( cwd.as_path(), env, None, + /*read_roots_override*/ None, + /*read_roots_include_platform_defaults*/ false, + /*write_roots_override*/ None, + /*deny_read_paths_override*/ &[], + /*deny_write_paths_override*/ &[], /*tty*/ false, /*stdin_open*/ true, config.permissions.windows_sandbox_private_desktop, @@ -384,6 +389,8 @@ async fn run_command_under_windows_session( cwd.as_path(), env, None, + /*additional_deny_read_paths*/ &[], + /*additional_deny_write_paths*/ &[], /*tty*/ false, /*stdin_open*/ true, config.permissions.windows_sandbox_private_desktop, diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index 6750cbf39e..5bea8ce78d 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -10,10 +10,10 @@ use std::path::PathBuf; pub use debug_sandbox::run_command_under_landlock; pub use debug_sandbox::run_command_under_seatbelt; pub use debug_sandbox::run_command_under_windows; -pub use login::read_agent_identity_from_stdin; +pub use login::read_access_token_from_stdin; pub use login::read_api_key_from_stdin; pub use login::run_login_status; -pub use login::run_login_with_agent_identity; +pub use login::run_login_with_access_token; pub use login::run_login_with_api_key; pub use login::run_login_with_chatgpt; pub use login::run_login_with_device_code; diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 1baa344b8d..16add7ac90 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -13,7 +13,7 @@ use codex_core::config::Config; use codex_login::CLIENT_ID; use codex_login::CodexAuth; use codex_login::ServerOptions; -use codex_login::login_with_agent_identity; +use codex_login::login_with_access_token; use codex_login::login_with_api_key; use codex_login::logout_with_revoke; use codex_login::run_device_code_login; @@ -35,8 +35,8 @@ const CHATGPT_LOGIN_DISABLED_MESSAGE: &str = "ChatGPT login is disabled. Use API key login instead."; const API_KEY_LOGIN_DISABLED_MESSAGE: &str = "API key login is disabled. Use ChatGPT login instead."; -const AGENT_IDENTITY_LOGIN_DISABLED_MESSAGE: &str = - "Agent Identity login is disabled. Use API key login instead."; +const ACCESS_TOKEN_LOGIN_DISABLED_MESSAGE: &str = + "Access token login is disabled. Use API key login instead."; const LOGIN_SUCCESS_MESSAGE: &str = "Successfully logged in"; /// Installs a small file-backed tracing layer for direct `codex login` flows. @@ -190,22 +190,22 @@ pub async fn run_login_with_api_key( } } -pub async fn run_login_with_agent_identity( +pub async fn run_login_with_access_token( cli_config_overrides: CliConfigOverrides, - agent_identity: String, + access_token: String, ) -> ! { let config = load_config_or_exit(cli_config_overrides).await; let _login_log_guard = init_login_file_logging(&config); - tracing::info!("starting agent identity login flow"); + tracing::info!("starting access token login flow"); if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { - eprintln!("{AGENT_IDENTITY_LOGIN_DISABLED_MESSAGE}"); + eprintln!("{ACCESS_TOKEN_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } - match login_with_agent_identity( + match login_with_access_token( &config.codex_home, - &agent_identity, + &access_token, config.cli_auth_credentials_store_mode, Some(&config.chatgpt_base_url), ) @@ -216,7 +216,7 @@ pub async fn run_login_with_agent_identity( std::process::exit(0); } Err(e) => { - eprintln!("Error logging in with Agent Identity: {e}"); + eprintln!("Error logging in with access token: {e}"); std::process::exit(1); } } @@ -230,11 +230,11 @@ pub fn read_api_key_from_stdin() -> String { ) } -pub fn read_agent_identity_from_stdin() -> String { +pub fn read_access_token_from_stdin() -> String { read_stdin_secret( - "--with-agent-identity expects the Agent Identity token on stdin. Try piping it, e.g. `printenv CODEX_AGENT_IDENTITY | codex login --with-agent-identity`.", - "Reading Agent Identity token from stdin...", - "No Agent Identity token provided via stdin.", + "--with-access-token expects the access token on stdin. Try piping it, e.g. `printenv CODEX_ACCESS_TOKEN | codex login --with-access-token`.", + "Reading access token from stdin...", + "No access token provided via stdin.", ) } @@ -388,7 +388,7 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { std::process::exit(0); } AuthMode::AgentIdentity => { - eprintln!("Logged in using Agent Identity"); + eprintln!("Logged in using access token"); std::process::exit(0); } }, diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 7b6e7448d4..6ee44215dd 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -3,6 +3,9 @@ use clap::CommandFactory; use clap::Parser; use clap_complete::Shell; use clap_complete::generate; +use codex_app_server_daemon::BootstrapOptions as AppServerBootstrapOptions; +use codex_app_server_daemon::LifecycleCommand as AppServerLifecycleCommand; +use codex_app_server_daemon::RemoteControlMode as AppServerRemoteControlMode; use codex_arg0::Arg0DispatchPaths; use codex_arg0::arg0_dispatch_or_else; use codex_chatgpt::apply_command::ApplyCommand; @@ -10,10 +13,10 @@ use codex_chatgpt::apply_command::run_apply_command; use codex_cli::LandlockCommand; use codex_cli::SeatbeltCommand; use codex_cli::WindowsCommand; -use codex_cli::read_agent_identity_from_stdin; +use codex_cli::read_access_token_from_stdin; use codex_cli::read_api_key_from_stdin; use codex_cli::run_login_status; -use codex_cli::run_login_with_agent_identity; +use codex_cli::run_login_with_access_token; use codex_cli::run_login_with_api_key; use codex_cli::run_login_with_chatgpt; use codex_cli::run_login_with_device_code; @@ -126,6 +129,9 @@ enum Subcommand { /// [experimental] Run the app server or related tooling. AppServer(AppServerCommand), + /// [experimental] Ensure the app-server daemon is running with remote control enabled. + RemoteControl, + /// Launch the Codex desktop app (opens the app installer if missing). #[cfg(any(target_os = "macos", target_os = "windows"))] App(app_cmd::AppCommand), @@ -364,10 +370,10 @@ struct LoginCommand { with_api_key: bool, #[arg( - long = "with-agent-identity", - help = "Read the experimental Agent Identity token from stdin (e.g. `printenv CODEX_AGENT_IDENTITY | codex login --with-agent-identity`)" + long = "with-access-token", + help = "Read the access token from stdin (e.g. `printenv CODEX_ACCESS_TOKEN | codex login --with-access-token`)" )] - with_agent_identity: bool, + with_access_token: bool, #[arg( long = "api-key", @@ -422,6 +428,10 @@ struct AppServerCommand { )] listen: codex_app_server::AppServerTransport, + /// Enable remote control for this app-server process. + #[arg(long = "remote-control", hide = true)] + remote_control: bool, + /// Controls whether analytics are enabled by default. /// /// Analytics are disabled by default for app-server. Users have to explicitly opt in @@ -446,18 +456,29 @@ struct AppServerCommand { #[derive(Debug, Parser)] struct ExecServerCommand { - /// Transport endpoint URL. Supported values: `ws://IP:PORT` (default). - #[arg( - long = "listen", - value_name = "URL", - default_value = "ws://127.0.0.1:0" - )] - listen: String, + /// Transport endpoint URL. Supported values: `ws://IP:PORT` (default), `stdio`, `stdio://`. + #[arg(long = "listen", value_name = "URL", conflicts_with = "remote")] + listen: Option, + + /// Register this exec-server as a remote executor using the given base URL. + #[arg(long = "remote", value_name = "URL", requires = "executor_id")] + remote: Option, + + /// Executor id to attach to when registering remotely. + #[arg(long = "executor-id", value_name = "ID")] + executor_id: Option, + + /// Human-readable executor name. + #[arg(long = "name", value_name = "NAME")] + name: Option, } #[derive(Debug, clap::Subcommand)] #[allow(clippy::enum_variant_names)] enum AppServerSubcommand { + /// Manage the local app-server daemon. + Daemon(AppServerDaemonCommand), + /// Proxy stdio bytes to the running app-server control socket. Proxy(AppServerProxyCommand), @@ -472,6 +493,40 @@ enum AppServerSubcommand { GenerateInternalJsonSchema(GenerateInternalJsonSchemaCommand), } +#[derive(Debug, Args)] +struct AppServerDaemonCommand { + #[command(subcommand)] + subcommand: AppServerDaemonSubcommand, +} + +#[derive(Debug, clap::Subcommand)] +enum AppServerDaemonSubcommand { + /// Install durable local app-server management for SSH-driven use. + Bootstrap(AppServerBootstrapCommand), + + /// Start the local app server daemon if it is not already running. + Start, + + /// Restart the local app server daemon. + Restart, + + /// Enable remote control for future starts and a currently running managed daemon. + EnableRemoteControl, + + /// Disable remote control for future starts and a currently running managed daemon. + DisableRemoteControl, + + /// Stop the local app server daemon. + Stop, + + /// Print local CLI and running app-server versions as JSON. + Version, + + /// [internal] Run the detached pid-backed standalone updater loop. + #[clap(hide = true)] + PidUpdateLoop, +} + #[derive(Debug, Args)] struct AppServerProxyCommand { /// Path to the app-server Unix domain socket to connect to. @@ -479,6 +534,13 @@ struct AppServerProxyCommand { socket_path: Option, } +#[derive(Debug, Args)] +struct AppServerBootstrapCommand { + /// Launch the managed app-server with remote control enabled. + #[arg(long = "remote-control")] + remote_control: bool, +} + #[derive(Debug, Args)] struct GenerateTsCommand { /// Output directory where .ts files will be written @@ -660,9 +722,9 @@ struct FeatureToggles { #[derive(Debug, Default, Parser, Clone)] struct InteractiveRemoteOptions { - /// Connect the TUI to a remote app server websocket endpoint. + /// Connect the TUI to a remote app server endpoint. /// - /// Accepted forms: `ws://host:port` or `wss://host:port`. + /// Accepted forms: `ws://host:port`, `wss://host:port`, `unix://`, or `unix://PATH`. #[arg(long = "remote", value_name = "ADDR")] remote: Option, @@ -833,6 +895,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { let AppServerCommand { subcommand, listen, + remote_control, analytics_default_enabled, auth, } = app_server_cli; @@ -845,7 +908,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { None => { let transport = listen; let auth = auth.try_into_settings()?; - codex_app_server::run_main_with_transport( + let runtime_options = codex_app_server::AppServerRuntimeOptions { + remote_control_enabled: remote_control, + ..Default::default() + }; + codex_app_server::run_main_with_transport_options( arg0_paths.clone(), root_config_overrides, codex_config::LoaderOverrides::default(), @@ -853,9 +920,45 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { transport, codex_protocol::protocol::SessionSource::VSCode, auth, + runtime_options, ) .await?; } + Some(AppServerSubcommand::Daemon(daemon_cli)) => match daemon_cli.subcommand { + AppServerDaemonSubcommand::Start => { + print_app_server_daemon_output(AppServerLifecycleCommand::Start).await?; + } + AppServerDaemonSubcommand::Bootstrap(bootstrap_cli) => { + let output = + codex_app_server_daemon::bootstrap(AppServerBootstrapOptions { + remote_control_enabled: bootstrap_cli.remote_control, + }) + .await?; + println!("{}", serde_json::to_string(&output)?); + } + AppServerDaemonSubcommand::Restart => { + print_app_server_daemon_output(AppServerLifecycleCommand::Restart).await?; + } + AppServerDaemonSubcommand::EnableRemoteControl => { + print_app_server_remote_control_output(AppServerRemoteControlMode::Enabled) + .await?; + } + AppServerDaemonSubcommand::DisableRemoteControl => { + print_app_server_remote_control_output( + AppServerRemoteControlMode::Disabled, + ) + .await?; + } + AppServerDaemonSubcommand::Stop => { + print_app_server_daemon_output(AppServerLifecycleCommand::Stop).await?; + } + AppServerDaemonSubcommand::Version => { + print_app_server_daemon_output(AppServerLifecycleCommand::Version).await?; + } + AppServerDaemonSubcommand::PidUpdateLoop => { + codex_app_server_daemon::run_pid_update_loop().await?; + } + }, Some(AppServerSubcommand::Proxy(proxy_cli)) => { let socket_path = match proxy_cli.socket_path { Some(socket_path) => socket_path, @@ -888,6 +991,15 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } } } + Some(Subcommand::RemoteControl) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "remote-control", + )?; + let output = codex_app_server_daemon::ensure_remote_control_started().await?; + println!("{}", serde_json::to_string(&output)?); + } #[cfg(any(target_os = "macos", target_os = "windows"))] Some(Subcommand::App(app_cli)) => { reject_remote_mode_for_subcommand( @@ -966,9 +1078,9 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { run_login_status(login_cli.config_overrides).await; } None => { - if login_cli.with_api_key && login_cli.with_agent_identity { + if login_cli.with_api_key && login_cli.with_access_token { eprintln!( - "Choose one login credential source: --with-api-key or --with-agent-identity." + "Choose one login credential source: --with-api-key or --with-access-token." ); std::process::exit(1); } else if login_cli.use_device_code { @@ -986,10 +1098,9 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } else if login_cli.with_api_key { let api_key = read_api_key_from_stdin(); run_login_with_api_key(login_cli.config_overrides, api_key).await; - } else if login_cli.with_agent_identity { - let agent_identity = read_agent_identity_from_stdin(); - run_login_with_agent_identity(login_cli.config_overrides, agent_identity) - .await; + } else if login_cli.with_access_token { + let access_token = read_access_token_from_stdin(); + run_login_with_access_token(login_cli.config_overrides, access_token).await; } else { run_login_with_chatgpt(login_cli.config_overrides).await; } @@ -1265,7 +1376,23 @@ async fn run_exec_server_command( codex_self_exe, arg0_paths.codex_linux_sandbox_exe.clone(), )?; - codex_exec_server::run_main(&cmd.listen, runtime_paths) + if let Some(base_url) = cmd.remote { + let executor_id = cmd + .executor_id + .ok_or_else(|| anyhow::anyhow!("--executor-id is required when --remote is set"))?; + let mut remote_config = + codex_exec_server::RemoteExecutorConfig::new(base_url, executor_id)?; + if let Some(name) = cmd.name { + remote_config.name = name; + } + codex_exec_server::run_remote_executor(remote_config, runtime_paths).await?; + return Ok(()); + } + let listen_url = cmd + .listen + .as_deref() + .unwrap_or(codex_exec_server::DEFAULT_LISTEN_URL); + codex_exec_server::run_main(listen_url, runtime_paths) .await .map_err(anyhow::Error::from_boxed) } @@ -1388,7 +1515,7 @@ async fn run_debug_prompt_input_command( }); } - let prompt_input = codex_core::build_prompt_input(config, input).await?; + let prompt_input = codex_core::build_prompt_input(config, input, /*state_db*/ None).await?; println!("{}", serde_json::to_string_pretty(&prompt_input)?); Ok(()) @@ -1495,6 +1622,20 @@ fn reject_remote_mode_for_app_server_subcommand( ) -> anyhow::Result<()> { let subcommand_name = match subcommand { None => "app-server", + Some(AppServerSubcommand::Daemon(daemon)) => match daemon.subcommand { + AppServerDaemonSubcommand::Bootstrap(_) => "app-server daemon bootstrap", + AppServerDaemonSubcommand::Start => "app-server daemon start", + AppServerDaemonSubcommand::Restart => "app-server daemon restart", + AppServerDaemonSubcommand::EnableRemoteControl => { + "app-server daemon enable-remote-control" + } + AppServerDaemonSubcommand::DisableRemoteControl => { + "app-server daemon disable-remote-control" + } + AppServerDaemonSubcommand::Stop => "app-server daemon stop", + AppServerDaemonSubcommand::Version => "app-server daemon version", + AppServerDaemonSubcommand::PidUpdateLoop => "app-server daemon pid-update-loop", + }, Some(AppServerSubcommand::Proxy(_)) => "app-server proxy", Some(AppServerSubcommand::GenerateTs(_)) => "app-server generate-ts", Some(AppServerSubcommand::GenerateJsonSchema(_)) => "app-server generate-json-schema", @@ -1505,6 +1646,20 @@ fn reject_remote_mode_for_app_server_subcommand( reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name) } +async fn print_app_server_daemon_output(command: AppServerLifecycleCommand) -> anyhow::Result<()> { + let output = codex_app_server_daemon::run(command).await?; + println!("{}", serde_json::to_string(&output)?); + Ok(()) +} + +async fn print_app_server_remote_control_output( + mode: AppServerRemoteControlMode, +) -> anyhow::Result<()> { + let output = codex_app_server_daemon::set_remote_control(mode).await?; + println!("{}", serde_json::to_string(&output)?); + Ok(()) +} + fn read_remote_auth_token_from_env_var_with( env_var_name: &str, get_var: F, @@ -1554,27 +1709,39 @@ async fn run_interactive_tui( } } - let normalized_remote = remote + let mut remote_endpoint = remote .as_deref() - .map(codex_tui::normalize_remote_addr) + .map(codex_tui::resolve_remote_addr) .transpose() .map_err(std::io::Error::other)?; - if remote_auth_token_env.is_some() && normalized_remote.is_none() { - return Ok(AppExitInfo::fatal( - "`--remote-auth-token-env` requires `--remote`.", - )); + if let Some(remote_auth_token_env) = remote_auth_token_env { + let Some(endpoint) = remote_endpoint.as_mut() else { + return Ok(AppExitInfo::fatal( + "`--remote-auth-token-env` requires `--remote`.", + )); + }; + if !codex_tui::remote_addr_supports_auth_token(endpoint) { + return Ok(AppExitInfo::fatal( + "`--remote-auth-token-env` requires a `wss://` or loopback `ws://` remote.", + )); + } + let auth_token = read_remote_auth_token_from_env_var(&remote_auth_token_env) + .map_err(std::io::Error::other)?; + let codex_tui::RemoteAppServerEndpoint::WebSocket { + auth_token: slot, .. + } = endpoint + else { + return Ok(AppExitInfo::fatal( + "`--remote-auth-token-env` requires a `wss://` or loopback `ws://` remote.", + )); + }; + *slot = Some(auth_token); } - let remote_auth_token = remote_auth_token_env - .as_deref() - .map(read_remote_auth_token_from_env_var) - .transpose() - .map_err(std::io::Error::other)?; codex_tui::run_main( interactive, arg0_paths, codex_config::LoaderOverrides::default(), - normalized_remote, - remote_auth_token, + remote_endpoint, ) .await } @@ -2240,6 +2407,7 @@ mod tests { fn app_server_analytics_default_disabled_without_flag() { let app_server = app_server_from_args(["codex", "app-server"].as_ref()); assert!(!app_server.analytics_default_enabled); + assert!(!app_server.remote_control); assert_eq!( app_server.listen, codex_app_server::AppServerTransport::Stdio @@ -2254,10 +2422,26 @@ mod tests { } #[test] - fn remote_flag_parses_for_interactive_root() { - let cli = MultitoolCli::try_parse_from(["codex", "--remote", "ws://127.0.0.1:4500"]) + fn reject_remote_flag_for_remote_control() { + let cli = MultitoolCli::try_parse_from(["codex", "--remote", "unix://", "remote-control"]) .expect("parse"); - assert_eq!(cli.remote.remote.as_deref(), Some("ws://127.0.0.1:4500")); + assert_matches!(cli.subcommand, Some(Subcommand::RemoteControl)); + + let err = reject_remote_mode_for_subcommand( + cli.remote.remote.as_deref(), + cli.remote.remote_auth_token_env.as_deref(), + "remote-control", + ) + .expect_err("remote-control should reject root --remote"); + + assert!(err.to_string().contains("remote-control")); + } + + #[test] + fn remote_flag_parses_for_interactive_root() { + let cli = MultitoolCli::try_parse_from(["codex", "--remote", "unix://codex.sock"]) + .expect("parse"); + assert_eq!(cli.remote.remote.as_deref(), Some("unix://codex.sock")); } #[test] @@ -2279,14 +2463,14 @@ mod tests { #[test] fn remote_flag_parses_for_resume_subcommand() { let cli = - MultitoolCli::try_parse_from(["codex", "resume", "--remote", "ws://127.0.0.1:4500"]) + MultitoolCli::try_parse_from(["codex", "resume", "--remote", "unix://codex.sock"]) .expect("parse"); let Subcommand::Resume(ResumeCommand { remote, .. }) = cli.subcommand.expect("resume present") else { panic!("expected resume subcommand"); }; - assert_eq!(remote.remote.as_deref(), Some("ws://127.0.0.1:4500")); + assert_eq!(remote.remote.as_deref(), Some("unix://codex.sock")); } #[test] @@ -2433,6 +2617,70 @@ mod tests { )); } + #[test] + fn app_server_daemon_subcommands_parse() { + assert!(matches!( + app_server_from_args( + [ + "codex", + "app-server", + "daemon", + "bootstrap", + "--remote-control" + ] + .as_ref() + ) + .subcommand, + Some(AppServerSubcommand::Daemon(AppServerDaemonCommand { + subcommand: AppServerDaemonSubcommand::Bootstrap(AppServerBootstrapCommand { + remote_control: true + }) + })) + )); + assert!(matches!( + app_server_from_args(["codex", "app-server", "daemon", "start"].as_ref()).subcommand, + Some(AppServerSubcommand::Daemon(AppServerDaemonCommand { + subcommand: AppServerDaemonSubcommand::Start + })) + )); + assert!(matches!( + app_server_from_args(["codex", "app-server", "daemon", "restart"].as_ref()).subcommand, + Some(AppServerSubcommand::Daemon(AppServerDaemonCommand { + subcommand: AppServerDaemonSubcommand::Restart + })) + )); + assert!(matches!( + app_server_from_args( + ["codex", "app-server", "daemon", "enable-remote-control"].as_ref() + ) + .subcommand, + Some(AppServerSubcommand::Daemon(AppServerDaemonCommand { + subcommand: AppServerDaemonSubcommand::EnableRemoteControl + })) + )); + assert!(matches!( + app_server_from_args( + ["codex", "app-server", "daemon", "disable-remote-control"].as_ref() + ) + .subcommand, + Some(AppServerSubcommand::Daemon(AppServerDaemonCommand { + subcommand: AppServerDaemonSubcommand::DisableRemoteControl + })) + )); + assert!(matches!( + app_server_from_args(["codex", "app-server", "daemon", "stop"].as_ref()).subcommand, + Some(AppServerSubcommand::Daemon(AppServerDaemonCommand { + subcommand: AppServerDaemonSubcommand::Stop + })) + )); + assert!(matches!( + app_server_from_args(["codex", "app-server", "daemon", "version"].as_ref()).subcommand, + Some(AppServerSubcommand::Daemon(AppServerDaemonCommand { + subcommand: AppServerDaemonSubcommand::Version + })) + )); + } + #[test] fn app_server_proxy_sock_path_parses() { let app_server = @@ -2461,6 +2709,20 @@ mod tests { assert!(err.to_string().contains("app-server proxy")); } + #[test] + fn reject_remote_auth_token_env_for_app_server_version() { + let subcommand = AppServerSubcommand::Daemon(AppServerDaemonCommand { + subcommand: AppServerDaemonSubcommand::Version, + }); + let err = reject_remote_mode_for_app_server_subcommand( + /*remote*/ None, + Some("CODEX_REMOTE_AUTH_TOKEN"), + Some(&subcommand), + ) + .expect_err("app-server daemon version should reject --remote-auth-token-env"); + assert!(err.to_string().contains("app-server daemon version")); + } + #[test] fn app_server_capability_token_flags_parse() { let app_server = app_server_from_args( diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 858ef442ae..af75999163 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -397,7 +397,7 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) let mcp_manager = McpManager::new(Arc::new(PluginsManager::new( config.codex_home.to_path_buf(), ))); - let mcp_servers = mcp_manager.effective_servers(&config, /*auth*/ None).await; + let mcp_servers = mcp_manager.configured_servers(&config).await; let LoginArgs { name, scopes } = login_args; @@ -450,7 +450,7 @@ async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutAr let mcp_manager = McpManager::new(Arc::new(PluginsManager::new( config.codex_home.to_path_buf(), ))); - let mcp_servers = mcp_manager.effective_servers(&config, /*auth*/ None).await; + let mcp_servers = mcp_manager.configured_servers(&config).await; let LogoutArgs { name } = logout_args; @@ -482,12 +482,13 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> let mcp_manager = McpManager::new(Arc::new(PluginsManager::new( config.codex_home.to_path_buf(), ))); - let mcp_servers = mcp_manager.effective_servers(&config, /*auth*/ None).await; + let mcp_servers = mcp_manager.configured_servers(&config).await; + let effective_mcp_servers = mcp_manager.effective_servers(&config, /*auth*/ None).await; let mut entries: Vec<_> = mcp_servers.iter().collect(); entries.sort_by(|(a, _), (b, _)| a.cmp(b)); let auth_statuses = compute_auth_statuses( - mcp_servers.iter(), + effective_mcp_servers.iter(), config.mcp_oauth_credentials_store_mode, /*auth*/ None, ) @@ -737,7 +738,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re let mcp_manager = McpManager::new(Arc::new(PluginsManager::new( config.codex_home.to_path_buf(), ))); - let mcp_servers = mcp_manager.effective_servers(&config, /*auth*/ None).await; + let mcp_servers = mcp_manager.configured_servers(&config).await; let Some(server) = mcp_servers.get(&get_args.name) else { bail!("No MCP server named '{name}' found.", name = get_args.name); diff --git a/codex-rs/cli/tests/login.rs b/codex-rs/cli/tests/login.rs index 7fd9f7af27..e290d05939 100644 --- a/codex-rs/cli/tests/login.rs +++ b/codex-rs/cli/tests/login.rs @@ -51,16 +51,16 @@ fn login_with_api_key_reads_stdin_and_writes_auth_json() -> Result<()> { } #[test] -fn login_with_agent_identity_rejects_invalid_jwt() -> Result<()> { +fn login_with_access_token_rejects_invalid_jwt() -> Result<()> { let codex_home = TempDir::new()?; write_file_auth_config(codex_home.path())?; let mut cmd = codex_command(codex_home.path())?; - cmd.args(["login", "--with-agent-identity"]) + cmd.args(["login", "--with-access-token"]) .write_stdin("not-a-jwt\n") .assert() .failure() - .stderr(contains("Error logging in with Agent Identity")); + .stderr(contains("Error logging in with access token")); Ok(()) } diff --git a/codex-rs/cloud-requirements/Cargo.toml b/codex-rs/cloud-requirements/Cargo.toml index 59f8741cdb..cc7aefc478 100644 --- a/codex-rs/cloud-requirements/Cargo.toml +++ b/codex-rs/cloud-requirements/Cargo.toml @@ -30,3 +30,6 @@ tracing = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "test-util", "time"] } + +[lib] +doctest = false diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 6f283b43d0..8812762cbe 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -829,6 +829,7 @@ mod tests { use super::*; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use codex_config::AppToolApproval; use codex_config::types::AuthCredentialsStoreMode; use codex_login::auth::AgentIdentityAuth; use codex_login::auth::AgentIdentityAuthRecord; @@ -1203,6 +1204,7 @@ mod tests { allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1285,6 +1287,7 @@ mod tests { allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1318,6 +1321,7 @@ mod tests { allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1368,6 +1372,7 @@ mod tests { allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1399,6 +1404,40 @@ enabled = false "connector_5f3c8c41a1e54ad7a76272c89e2554fa".to_string(), codex_config::AppRequirementToml { enabled: Some(false), + tools: None, + }, + )]), + }), + ..Default::default() + }) + ); + } + + #[tokio::test] + async fn fetch_cloud_requirements_parses_apps_tool_requirements_toml() { + let result = parse_for_fetch(Some( + r#" +[apps.connector_5f3c8c41a1e54ad7a76272c89e2554fa.tools."calendar/list_events"] +approval_mode = "approve" +"#, + )); + + assert_eq!( + result, + Some(ConfigRequirementsToml { + apps: Some(codex_config::AppsRequirementsToml { + apps: BTreeMap::from([( + "connector_5f3c8c41a1e54ad7a76272c89e2554fa".to_string(), + codex_config::AppRequirementToml { + enabled: None, + tools: Some(codex_config::AppToolsRequirementsToml { + tools: BTreeMap::from([( + "calendar/list_events".to_string(), + codex_config::AppToolRequirementToml { + approval_mode: Some(AppToolApproval::Approve), + }, + )]), + }), }, )]), }), @@ -1484,6 +1523,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1564,6 +1604,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1642,6 +1683,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1848,6 +1890,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1888,6 +1931,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -1948,6 +1992,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2004,6 +2049,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2062,6 +2108,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2121,6 +2168,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2180,6 +2228,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2269,6 +2318,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, @@ -2300,6 +2350,7 @@ command = "sample-mcp" allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, guardian_policy_config: None, feature_requirements: None, hooks: None, diff --git a/codex-rs/cloud-tasks-client/Cargo.toml b/codex-rs/cloud-tasks-client/Cargo.toml index 929c3e3136..df8ec12b20 100644 --- a/codex-rs/cloud-tasks-client/Cargo.toml +++ b/codex-rs/cloud-tasks-client/Cargo.toml @@ -7,6 +7,8 @@ version.workspace = true [lib] name = "codex_cloud_tasks_client" path = "src/lib.rs" +test = false +doctest = false [lints] workspace = true diff --git a/codex-rs/cloud-tasks-mock-client/Cargo.toml b/codex-rs/cloud-tasks-mock-client/Cargo.toml index 7282929382..b4531cff63 100644 --- a/codex-rs/cloud-tasks-mock-client/Cargo.toml +++ b/codex-rs/cloud-tasks-mock-client/Cargo.toml @@ -8,6 +8,8 @@ version.workspace = true [lib] name = "codex_cloud_tasks_mock_client" path = "src/lib.rs" +test = false +doctest = false [lints] workspace = true diff --git a/codex-rs/cloud-tasks/Cargo.toml b/codex-rs/cloud-tasks/Cargo.toml index 6429c1edcd..7bdcaaddba 100644 --- a/codex-rs/cloud-tasks/Cargo.toml +++ b/codex-rs/cloud-tasks/Cargo.toml @@ -7,6 +7,7 @@ version.workspace = true [lib] name = "codex_cloud_tasks" path = "src/lib.rs" +doctest = false [lints] workspace = true diff --git a/codex-rs/code-mode/Cargo.toml b/codex-rs/code-mode/Cargo.toml index 23b2ce2306..19d6c3ab45 100644 --- a/codex-rs/code-mode/Cargo.toml +++ b/codex-rs/code-mode/Cargo.toml @@ -9,6 +9,9 @@ doctest = false name = "codex_code_mode" path = "src/lib.rs" +[features] +sandbox = ["v8/v8_enable_sandbox"] + [lints] workspace = true diff --git a/codex-rs/code-mode/src/lib.rs b/codex-rs/code-mode/src/lib.rs index bf0ce699cc..3da8c77325 100644 --- a/codex-rs/code-mode/src/lib.rs +++ b/codex-rs/code-mode/src/lib.rs @@ -23,9 +23,12 @@ pub use runtime::DEFAULT_EXEC_YIELD_TIME_MS; pub use runtime::DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL; pub use runtime::DEFAULT_WAIT_YIELD_TIME_MS; pub use runtime::ExecuteRequest; +pub use runtime::ExecuteToPendingOutcome; pub use runtime::RuntimeResponse; pub use runtime::WaitOutcome; pub use runtime::WaitRequest; +pub use runtime::WaitToPendingOutcome; +pub use runtime::WaitToPendingRequest; pub use service::CodeModeService; pub use service::CodeModeTurnHost; pub use service::CodeModeTurnWorker; diff --git a/codex-rs/code-mode/src/runtime/callbacks.rs b/codex-rs/code-mode/src/runtime/callbacks.rs index a9755f6eb0..c3a648ae32 100644 --- a/codex-rs/code-mode/src/runtime/callbacks.rs +++ b/codex-rs/code-mode/src/runtime/callbacks.rs @@ -42,20 +42,16 @@ pub(super) fn tool_callback( let promise = resolver.get_promise(scope); let resolver = v8::Global::new(scope, resolver); - let tool_name = { + let (tool_name, tool_kind) = { let Some(state) = scope.get_slot::() else { throw_type_error(scope, "runtime state unavailable"); return; }; - let Some(tool_name) = state - .enabled_tools - .get(tool_index) - .map(|tool| tool.tool_name.clone()) - else { + let Some(tool) = state.enabled_tools.get(tool_index) else { throw_type_error(scope, "tool callback data is out of range"); return; }; - tool_name + (tool.tool_name.clone(), tool.kind) }; let Some(state) = scope.get_slot_mut::() else { @@ -69,6 +65,7 @@ pub(super) fn tool_callback( let _ = event_tx.send(RuntimeEvent::ToolCall { id, name: tool_name, + kind: tool_kind, input, }); retval.set(promise.into()); diff --git a/codex-rs/code-mode/src/runtime/globals.rs b/codex-rs/code-mode/src/runtime/globals.rs index b40136c44c..2ec6953f09 100644 --- a/codex-rs/code-mode/src/runtime/globals.rs +++ b/codex-rs/code-mode/src/runtime/globals.rs @@ -12,11 +12,10 @@ use super::callbacks::yield_control_callback; pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), String> { let global = scope.get_current_context().global(scope); - let console = v8::String::new(scope, "console") - .ok_or_else(|| "failed to allocate global `console`".to_string())?; - if global.delete(scope, console.into()) != Some(true) { - return Err("failed to remove global `console`".to_string()); - } + delete_global(scope, global, "console")?; + delete_global(scope, global, "Atomics")?; + delete_global(scope, global, "SharedArrayBuffer")?; + delete_global(scope, global, "WebAssembly")?; let tools = build_tools_object(scope)?; let all_tools = build_all_tools_value(scope)?; @@ -142,3 +141,17 @@ fn set_global<'s>( Err(format!("failed to set global `{name}`")) } } + +fn delete_global<'s>( + scope: &mut v8::PinScope<'s, '_>, + global: v8::Local<'s, v8::Object>, + name: &str, +) -> Result<(), String> { + let key = v8::String::new(scope, name) + .ok_or_else(|| format!("failed to allocate global `{name}`"))?; + if global.delete(scope, key.into()) == Some(true) { + Ok(()) + } else { + Err(format!("failed to remove global `{name}`")) + } +} diff --git a/codex-rs/code-mode/src/runtime/mod.rs b/codex-rs/code-mode/src/runtime/mod.rs index 200a47c989..2b605fa573 100644 --- a/codex-rs/code-mode/src/runtime/mod.rs +++ b/codex-rs/code-mode/src/runtime/mod.rs @@ -14,6 +14,7 @@ use serde::Serialize; use serde_json::Value as JsonValue; use tokio::sync::mpsc; +use crate::description::CodeModeToolKind; use crate::description::EnabledToolMetadata; use crate::description::ToolDefinition; use crate::description::enabled_tool_metadata; @@ -46,6 +47,11 @@ pub struct WaitRequest { pub terminate: bool, } +#[derive(Clone, Debug)] +pub struct WaitToPendingRequest { + pub cell_id: String, +} + /// Result of waiting on a code-mode cell. /// /// The wrapped `RuntimeResponse` is the model-facing wait result. The enum @@ -61,6 +67,34 @@ pub enum WaitOutcome { MissingCell(RuntimeResponse), } +/// Result of executing a code-mode cell until it either completes or reaches a +/// quiescent pending state. +#[derive(Debug, PartialEq)] +pub enum ExecuteToPendingOutcome { + /// The cell is waiting for more runtime input after draining the runtime + /// input queue that was ready at the pending boundary. + Pending { + cell_id: String, + content_items: Vec, + /// Runtime tool-call ids emitted before this paused execution frontier + /// sealed. Hosts can use these ids to drain their tool-call transport + /// before surfacing the pending boundary to callers. + pending_tool_call_ids: Vec, + }, + /// The cell reached a terminal runtime response before going pending. + Completed(RuntimeResponse), +} + +/// Result of resuming a live code-mode cell until it completes or becomes +/// quiescent again. +#[derive(Debug, PartialEq)] +pub enum WaitToPendingOutcome { + /// The requested code cell was live when the wait command was accepted. + LiveCell(ExecuteToPendingOutcome), + /// The requested code cell was not live. + MissingCell(RuntimeResponse), +} + impl From for RuntimeResponse { fn from(outcome: WaitOutcome) -> Self { match outcome { @@ -97,6 +131,7 @@ pub struct CodeModeNestedToolCall { pub cell_id: String, pub runtime_tool_call_id: String, pub tool_name: ToolName, + pub tool_kind: CodeModeToolKind, pub input: Option, } @@ -118,14 +153,28 @@ pub(crate) enum RuntimeCommand { Terminate, } +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) enum PendingRuntimeMode { + Continue, + PauseUntilResumed, +} + +#[derive(Debug)] +pub(crate) enum RuntimeControlCommand { + Resume, + Terminate, +} + #[derive(Debug)] pub(crate) enum RuntimeEvent { Started, + Pending, ContentItem(FunctionCallOutputContentItem), YieldRequested, ToolCall { id: String, name: ToolName, + kind: CodeModeToolKind, input: Option, }, Notify { @@ -141,10 +190,19 @@ pub(crate) enum RuntimeEvent { pub(crate) fn spawn_runtime( request: ExecuteRequest, event_tx: mpsc::UnboundedSender, -) -> Result<(std_mpsc::Sender, v8::IsolateHandle), String> { + pending_mode: PendingRuntimeMode, +) -> Result< + ( + std_mpsc::Sender, + std_mpsc::Sender, + v8::IsolateHandle, + ), + String, +> { initialize_v8()?; let (command_tx, command_rx) = std_mpsc::channel(); + let (control_tx, control_rx) = std_mpsc::channel(); let runtime_command_tx = command_tx.clone(); let (isolate_handle_tx, isolate_handle_rx) = std_mpsc::sync_channel(1); let enabled_tools = request @@ -164,6 +222,8 @@ pub(crate) fn spawn_runtime( config, event_tx, command_rx, + control_rx, + pending_mode, isolate_handle_tx, runtime_command_tx, ); @@ -172,7 +232,7 @@ pub(crate) fn spawn_runtime( let isolate_handle = isolate_handle_rx .recv() .map_err(|_| "failed to initialize code mode runtime".to_string())?; - Ok((command_tx, isolate_handle)) + Ok((command_tx, control_tx, isolate_handle)) } #[derive(Clone)] @@ -224,6 +284,8 @@ fn run_runtime( config: RuntimeConfig, event_tx: mpsc::UnboundedSender, command_rx: std_mpsc::Receiver, + control_rx: std_mpsc::Receiver, + pending_mode: PendingRuntimeMode, isolate_handle_tx: std_mpsc::SyncSender, runtime_command_tx: std_mpsc::Sender, ) { @@ -279,7 +341,8 @@ fn run_runtime( let mut pending_promise = pending_promise; loop { - let Ok(command) = command_rx.recv() else { + let Some(command) = next_runtime_command(&event_tx, &command_rx, &control_rx, pending_mode) + else { break; }; @@ -330,6 +393,30 @@ fn run_runtime( } } +fn next_runtime_command( + event_tx: &mpsc::UnboundedSender, + command_rx: &std_mpsc::Receiver, + control_rx: &std_mpsc::Receiver, + pending_mode: PendingRuntimeMode, +) -> Option { + loop { + match command_rx.try_recv() { + Ok(command) => return Some(command), + Err(std_mpsc::TryRecvError::Disconnected) => return None, + Err(std_mpsc::TryRecvError::Empty) => {} + } + + let _ = event_tx.send(RuntimeEvent::Pending); + match pending_mode { + PendingRuntimeMode::Continue => return command_rx.recv().ok(), + PendingRuntimeMode::PauseUntilResumed => match control_rx.recv().ok()? { + RuntimeControlCommand::Resume => continue, + RuntimeControlCommand::Terminate => return Some(RuntimeCommand::Terminate), + }, + } + } +} + fn capture_scope_send_error( scope: &mut v8::PinScope<'_, '_>, event_tx: &mpsc::UnboundedSender, @@ -363,8 +450,12 @@ mod tests { use tokio::sync::mpsc; use super::ExecuteRequest; + use super::PendingRuntimeMode; + use super::RuntimeCommand; + use super::RuntimeControlCommand; use super::RuntimeEvent; use super::spawn_runtime; + use crate::FunctionCallOutputContentItem; fn execute_request(source: &str) -> ExecuteRequest { ExecuteRequest { @@ -381,8 +472,12 @@ mod tests { #[tokio::test] async fn terminate_execution_stops_cpu_bound_module() { let (event_tx, mut event_rx) = mpsc::unbounded_channel(); - let (_runtime_tx, runtime_terminate_handle) = - spawn_runtime(execute_request("while (true) {}"), event_tx).unwrap(); + let (_runtime_tx, _runtime_control_tx, runtime_terminate_handle) = spawn_runtime( + execute_request("while (true) {}"), + event_tx, + PendingRuntimeMode::Continue, + ) + .unwrap(); let started_event = tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) .await @@ -413,4 +508,71 @@ mod tests { .is_none() ); } + + #[tokio::test] + async fn pending_mode_freezes_runtime_commands_until_resume() { + let (event_tx, mut event_rx) = mpsc::unbounded_channel(); + let (runtime_tx, runtime_control_tx, _runtime_terminate_handle) = spawn_runtime( + execute_request( + r#" +await new Promise((resolve) => setTimeout(resolve, 60_000)); +text("after"); +await new Promise(() => {}); +"#, + ), + event_tx, + PendingRuntimeMode::PauseUntilResumed, + ) + .unwrap(); + + assert!(matches!( + tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(), + RuntimeEvent::Started + )); + assert!(matches!( + tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(), + RuntimeEvent::Pending + )); + + runtime_tx + .send(RuntimeCommand::TimeoutFired { id: 1 }) + .unwrap(); + assert!( + tokio::time::timeout(Duration::from_millis(100), event_rx.recv()) + .await + .is_err() + ); + + runtime_control_tx + .send(RuntimeControlCommand::Resume) + .unwrap(); + + let content_event = tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(); + let RuntimeEvent::ContentItem(FunctionCallOutputContentItem::InputText { text }) = + content_event + else { + panic!("expected resumed runtime output"); + }; + assert_eq!(text, "after"); + assert!(matches!( + tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) + .await + .unwrap() + .unwrap(), + RuntimeEvent::Pending + )); + + runtime_control_tx + .send(RuntimeControlCommand::Terminate) + .unwrap(); + } } diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs index 7326c834e2..44e4be4939 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode/src/service.rs @@ -16,12 +16,17 @@ use crate::FunctionCallOutputContentItem; use crate::runtime::CodeModeNestedToolCall; use crate::runtime::DEFAULT_EXEC_YIELD_TIME_MS; use crate::runtime::ExecuteRequest; +use crate::runtime::ExecuteToPendingOutcome; +use crate::runtime::PendingRuntimeMode; use crate::runtime::RuntimeCommand; +use crate::runtime::RuntimeControlCommand; use crate::runtime::RuntimeEvent; use crate::runtime::RuntimeResponse; use crate::runtime::TurnMessage; use crate::runtime::WaitOutcome; use crate::runtime::WaitRequest; +use crate::runtime::WaitToPendingOutcome; +use crate::runtime::WaitToPendingRequest; use crate::runtime::spawn_runtime; #[async_trait] @@ -90,18 +95,57 @@ impl CodeModeService { } pub async fn execute(&self, request: ExecuteRequest) -> Result { - let cell_id = request.cell_id.clone(); let initial_yield_time_ms = request.yield_time_ms.unwrap_or(DEFAULT_EXEC_YIELD_TIME_MS); + let (response_tx, response_rx) = oneshot::channel(); + self.start_session( + request, + SessionResponseSender::Runtime(response_tx), + Some(initial_yield_time_ms), + PendingRuntimeMode::Continue, + ) + .await?; + + response_rx + .await + .map_err(|_| "exec runtime ended unexpectedly".to_string()) + } + + pub async fn execute_to_pending( + &self, + request: ExecuteRequest, + ) -> Result { + let (response_tx, response_rx) = oneshot::channel(); + self.start_session( + request, + SessionResponseSender::ExecuteToPending(response_tx), + /*initial_yield_time_ms*/ None, + PendingRuntimeMode::PauseUntilResumed, + ) + .await?; + + response_rx + .await + .map_err(|_| "exec runtime ended unexpectedly".to_string()) + } + + async fn start_session( + &self, + request: ExecuteRequest, + initial_response_tx: SessionResponseSender, + initial_yield_time_ms: Option, + pending_mode: PendingRuntimeMode, + ) -> Result<(), String> { + let cell_id = request.cell_id.clone(); let (event_tx, event_rx) = mpsc::unbounded_channel(); let (control_tx, control_rx) = mpsc::unbounded_channel(); - let (response_tx, response_rx) = oneshot::channel(); - let (runtime_tx, runtime_terminate_handle) = { + let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = { let mut sessions = self.inner.sessions.lock().await; if sessions.contains_key(&cell_id) { return Err(format!("exec cell {cell_id} already exists")); } - let (runtime_tx, runtime_terminate_handle) = spawn_runtime(request, event_tx)?; + let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = + spawn_runtime(request, event_tx, pending_mode)?; // Keep the session registry locked through insertion so a // caller-owned cell id cannot race with another execute and replace @@ -113,7 +157,7 @@ impl CodeModeService { runtime_tx: runtime_tx.clone(), }, ); - (runtime_tx, runtime_terminate_handle) + (runtime_tx, runtime_control_tx, runtime_terminate_handle) }; tokio::spawn(run_session_control( @@ -121,17 +165,17 @@ impl CodeModeService { SessionControlContext { cell_id: cell_id.clone(), runtime_tx, + runtime_control_tx, + pending_mode, runtime_terminate_handle, }, event_rx, control_rx, - response_tx, + initial_response_tx, initial_yield_time_ms, )); - response_rx - .await - .map_err(|_| "exec runtime ended unexpectedly".to_string()) + Ok(()) } pub async fn wait(&self, request: WaitRequest) -> Result { @@ -166,6 +210,41 @@ impl CodeModeService { } } + pub async fn wait_to_pending( + &self, + request: WaitToPendingRequest, + ) -> Result { + let cell_id = request.cell_id.clone(); + let handle = self + .inner + .sessions + .lock() + .await + .get(&request.cell_id) + .cloned(); + let Some(handle) = handle else { + return Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( + cell_id, + ))); + }; + let (response_tx, response_rx) = oneshot::channel(); + if handle + .control_tx + .send(SessionControlCommand::PollToPending { response_tx }) + .is_err() + { + return Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( + cell_id, + ))); + } + match response_rx.await { + Ok(response) => Ok(WaitToPendingOutcome::LiveCell(response)), + Err(_) => Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( + request.cell_id, + ))), + } + } + pub fn start_turn_worker(&self, host: Arc) -> CodeModeTurnWorker { let (shutdown_tx, mut shutdown_rx) = oneshot::channel(); let inner = Arc::clone(&self.inner); @@ -255,11 +334,19 @@ enum SessionControlCommand { yield_time_ms: u64, response_tx: oneshot::Sender, }, + PollToPending { + response_tx: oneshot::Sender, + }, Terminate { response_tx: oneshot::Sender, }, } +enum SessionResponseSender { + Runtime(oneshot::Sender), + ExecuteToPending(oneshot::Sender), +} + struct PendingResult { content_items: Vec, stored_values: HashMap, @@ -269,6 +356,8 @@ struct PendingResult { struct SessionControlContext { cell_id: String, runtime_tx: std::sync::mpsc::Sender, + runtime_control_tx: std::sync::mpsc::Sender, + pending_mode: PendingRuntimeMode, runtime_terminate_handle: v8::IsolateHandle, } @@ -290,14 +379,26 @@ fn pending_result_response(cell_id: &str, result: PendingResult) -> RuntimeRespo } } +fn send_terminal_response(response_tx: SessionResponseSender, response: RuntimeResponse) { + match response_tx { + SessionResponseSender::Runtime(response_tx) => { + let _ = response_tx.send(response); + } + SessionResponseSender::ExecuteToPending(response_tx) => { + let _ = response_tx.send(ExecuteToPendingOutcome::Completed(response)); + } + } +} + fn send_or_buffer_result( cell_id: &str, result: PendingResult, - response_tx: &mut Option>, + response_tx: &mut Option, pending_result: &mut Option, ) -> bool { if let Some(response_tx) = response_tx.take() { - let _ = response_tx.send(pending_result_response(cell_id, result)); + let response = pending_result_response(cell_id, result); + send_terminal_response(response_tx, response); return true; } @@ -305,20 +406,46 @@ fn send_or_buffer_result( false } +fn send_yield_response( + cell_id: &str, + content_items: &mut Vec, + response_tx: &mut Option, +) { + let Some(current_response_tx) = response_tx.take() else { + return; + }; + match current_response_tx { + SessionResponseSender::Runtime(response_tx) => { + let _ = response_tx.send(RuntimeResponse::Yielded { + cell_id: cell_id.to_string(), + content_items: std::mem::take(content_items), + }); + } + SessionResponseSender::ExecuteToPending(execute_to_pending_tx) => { + *response_tx = Some(SessionResponseSender::ExecuteToPending( + execute_to_pending_tx, + )); + } + } +} + async fn run_session_control( inner: Arc, context: SessionControlContext, mut event_rx: mpsc::UnboundedReceiver, mut control_rx: mpsc::UnboundedReceiver, - initial_response_tx: oneshot::Sender, - initial_yield_time_ms: u64, + initial_response_tx: SessionResponseSender, + initial_yield_time_ms: Option, ) { let SessionControlContext { cell_id, runtime_tx, + runtime_control_tx, + pending_mode, runtime_terminate_handle, } = context; let mut content_items = Vec::new(); + let mut pending_tool_call_ids = Vec::new(); let mut pending_result: Option = None; let mut response_tx = Some(initial_response_tx); let mut termination_requested = false; @@ -338,10 +465,11 @@ async fn run_session_control( runtime_closed = true; if termination_requested { if let Some(response_tx) = response_tx.take() { - let _ = response_tx.send(RuntimeResponse::Terminated { + let response = RuntimeResponse::Terminated { cell_id: cell_id.clone(), content_items: std::mem::take(&mut content_items), - }); + }; + send_terminal_response(response_tx, response); } break; } @@ -364,19 +492,35 @@ async fn run_session_control( }; match event { RuntimeEvent::Started => { - yield_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(initial_yield_time_ms)))); + yield_timer = initial_yield_time_ms.map(|initial_yield_time_ms| { + Box::pin(tokio::time::sleep(Duration::from_millis(initial_yield_time_ms))) + }); + } + RuntimeEvent::Pending => { + if let Some(current_response_tx) = response_tx.take() { + match current_response_tx { + SessionResponseSender::Runtime(runtime_response_tx) => { + response_tx = + Some(SessionResponseSender::Runtime(runtime_response_tx)); + } + SessionResponseSender::ExecuteToPending(response_tx) => { + let _ = response_tx.send(ExecuteToPendingOutcome::Pending { + cell_id: cell_id.clone(), + content_items: std::mem::take(&mut content_items), + pending_tool_call_ids: std::mem::take( + &mut pending_tool_call_ids, + ), + }); + } + } + } } RuntimeEvent::ContentItem(item) => { content_items.push(item); } RuntimeEvent::YieldRequested => { yield_timer = None; - if let Some(response_tx) = response_tx.take() { - let _ = response_tx.send(RuntimeResponse::Yielded { - cell_id: cell_id.clone(), - content_items: std::mem::take(&mut content_items), - }); - } + send_yield_response(&cell_id, &mut content_items, &mut response_tx); } RuntimeEvent::Notify { call_id, text } => { let _ = inner.turn_message_tx.send(TurnMessage::Notify { @@ -385,11 +529,20 @@ async fn run_session_control( text, }).await; } - RuntimeEvent::ToolCall { id, name, input } => { + RuntimeEvent::ToolCall { + id, + name, + kind, + input, + } => { + if pending_mode == PendingRuntimeMode::PauseUntilResumed { + pending_tool_call_ids.push(id.clone()); + } let tool_call = CodeModeNestedToolCall { cell_id: cell_id.clone(), runtime_tool_call_id: id, tool_name: name, + tool_kind: kind, input, }; let _ = inner @@ -404,10 +557,11 @@ async fn run_session_control( yield_timer = None; if termination_requested { if let Some(response_tx) = response_tx.take() { - let _ = response_tx.send(RuntimeResponse::Terminated { + let response = RuntimeResponse::Terminated { cell_id: cell_id.clone(), content_items: std::mem::take(&mut content_items), - }); + }; + send_terminal_response(response_tx, response); } break; } @@ -440,8 +594,23 @@ async fn run_session_control( let _ = next_response_tx.send(pending_result_response(&cell_id, result)); break; } - response_tx = Some(next_response_tx); + response_tx = Some(SessionResponseSender::Runtime(next_response_tx)); yield_timer = Some(Box::pin(tokio::time::sleep(Duration::from_millis(yield_time_ms)))); + resume_paused_runtime(&runtime_control_tx, pending_mode); + } + SessionControlCommand::PollToPending { + response_tx: next_response_tx, + } => { + if let Some(result) = pending_result.take() { + let response = pending_result_response(&cell_id, result); + let _ = next_response_tx + .send(ExecuteToPendingOutcome::Completed(response)); + break; + } + response_tx = + Some(SessionResponseSender::ExecuteToPending(next_response_tx)); + yield_timer = None; + resume_paused_runtime(&runtime_control_tx, pending_mode); } SessionControlCommand::Terminate { response_tx: next_response_tx } => { if let Some(result) = pending_result.take() { @@ -449,17 +618,19 @@ async fn run_session_control( break; } - response_tx = Some(next_response_tx); + response_tx = Some(SessionResponseSender::Runtime(next_response_tx)); termination_requested = true; yield_timer = None; let _ = runtime_tx.send(RuntimeCommand::Terminate); + terminate_paused_runtime(&runtime_control_tx, pending_mode); let _ = runtime_terminate_handle.terminate_execution(); if runtime_closed { if let Some(response_tx) = response_tx.take() { - let _ = response_tx.send(RuntimeResponse::Terminated { + let response = RuntimeResponse::Terminated { cell_id: cell_id.clone(), content_items: std::mem::take(&mut content_items), - }); + }; + send_terminal_response(response_tx, response); } break; } else { @@ -476,20 +647,34 @@ async fn run_session_control( } } => { yield_timer = None; - if let Some(response_tx) = response_tx.take() { - let _ = response_tx.send(RuntimeResponse::Yielded { - cell_id: cell_id.clone(), - content_items: std::mem::take(&mut content_items), - }); - } + send_yield_response(&cell_id, &mut content_items, &mut response_tx); } } } let _ = runtime_tx.send(RuntimeCommand::Terminate); + terminate_paused_runtime(&runtime_control_tx, pending_mode); inner.sessions.lock().await.remove(&cell_id); } +fn resume_paused_runtime( + runtime_control_tx: &std::sync::mpsc::Sender, + pending_mode: PendingRuntimeMode, +) { + if pending_mode == PendingRuntimeMode::PauseUntilResumed { + let _ = runtime_control_tx.send(RuntimeControlCommand::Resume); + } +} + +fn terminate_paused_runtime( + runtime_control_tx: &std::sync::mpsc::Sender, + pending_mode: PendingRuntimeMode, +) { + if pending_mode == PendingRuntimeMode::PauseUntilResumed { + let _ = runtime_control_tx.send(RuntimeControlCommand::Terminate); + } +} + #[cfg(test)] mod tests { use std::collections::HashMap; @@ -497,6 +682,7 @@ mod tests { use std::sync::atomic::AtomicU64; use std::time::Duration; + use codex_protocol::ToolName; use pretty_assertions::assert_eq; use tokio::sync::Mutex; use tokio::sync::mpsc; @@ -504,15 +690,22 @@ mod tests { use super::CodeModeService; use super::Inner; + use super::PendingRuntimeMode; use super::RuntimeCommand; use super::RuntimeResponse; use super::SessionControlCommand; use super::SessionControlContext; + use super::SessionResponseSender; use super::WaitOutcome; use super::WaitRequest; + use super::WaitToPendingOutcome; + use super::WaitToPendingRequest; use super::run_session_control; + use crate::CodeModeToolKind; use crate::FunctionCallOutputContentItem; + use crate::ToolDefinition; use crate::runtime::ExecuteRequest; + use crate::runtime::ExecuteToPendingOutcome; use crate::runtime::RuntimeEvent; use crate::runtime::spawn_runtime; @@ -565,6 +758,363 @@ mod tests { ); } + #[tokio::test] + async fn execute_to_pending_returns_completed_for_synchronous_results() { + let service = CodeModeService::new(); + + let response = service + .execute_to_pending(ExecuteRequest { + source: r#"text("done");"#.to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + ExecuteToPendingOutcome::Completed(RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "done".to_string(), + }], + stored_values: HashMap::new(), + error_text: None, + }) + ); + } + + #[tokio::test] + async fn execute_to_pending_returns_once_the_runtime_is_quiescent() { + let service = CodeModeService::new(); + + let response = tokio::time::timeout( + Duration::from_secs(1), + service.execute_to_pending(ExecuteRequest { + source: r#"text("before"); await new Promise(() => {});"#.to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + response, + ExecuteToPendingOutcome::Pending { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "before".to_string(), + }], + pending_tool_call_ids: Vec::new(), + } + ); + + let termination = service + .wait(WaitRequest { + cell_id: "1".to_string(), + yield_time_ms: 1, + terminate: true, + }) + .await + .unwrap(); + + assert_eq!( + termination, + WaitOutcome::LiveCell(RuntimeResponse::Terminated { + cell_id: "1".to_string(), + content_items: Vec::new(), + }) + ); + } + + #[tokio::test] + async fn execute_to_pending_identifies_tool_calls_in_paused_frontier() { + let service = CodeModeService::new(); + + let response = service + .execute_to_pending(ExecuteRequest { + enabled_tools: vec![ToolDefinition { + name: "echo".to_string(), + tool_name: ToolName::plain("echo"), + description: String::new(), + kind: CodeModeToolKind::Function, + input_schema: None, + output_schema: None, + }], + source: r#" +await Promise.all([ + tools.echo({ value: "first" }), + tools.echo({ value: "second" }), +]); +"# + .to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + response, + ExecuteToPendingOutcome::Pending { + cell_id: "1".to_string(), + content_items: Vec::new(), + pending_tool_call_ids: vec!["tool-1".to_string(), "tool-2".to_string()], + } + ); + + let termination = service + .wait(WaitRequest { + cell_id: "1".to_string(), + yield_time_ms: 1, + terminate: true, + }) + .await + .unwrap(); + + assert_eq!( + termination, + WaitOutcome::LiveCell(RuntimeResponse::Terminated { + cell_id: "1".to_string(), + content_items: Vec::new(), + }) + ); + } + + #[tokio::test] + async fn execute_to_pending_excludes_delayed_timeout_tool_calls_until_wait() { + let service = CodeModeService::new(); + + let initial_response = service + .execute_to_pending(ExecuteRequest { + enabled_tools: vec![ToolDefinition { + name: "echo".to_string(), + tool_name: ToolName::plain("echo"), + description: String::new(), + kind: CodeModeToolKind::Function, + input_schema: None, + output_schema: None, + }], + source: r#" +setTimeout(() => { + tools.echo({ value: "delayed" }); +}, 1000); +await Promise.all([ + tools.echo({ value: "second" }), + tools.echo({ value: "third" }), +]); +"# + .to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + initial_response, + ExecuteToPendingOutcome::Pending { + cell_id: "1".to_string(), + content_items: Vec::new(), + pending_tool_call_ids: vec!["tool-1".to_string(), "tool-2".to_string()], + } + ); + + let runtime_tx = service + .inner + .sessions + .lock() + .await + .get("1") + .unwrap() + .runtime_tx + .clone(); + runtime_tx + .send(RuntimeCommand::TimeoutFired { id: 1 }) + .unwrap(); + + let resumed_response = tokio::time::timeout( + Duration::from_secs(1), + service.wait_to_pending(WaitToPendingRequest { + cell_id: "1".to_string(), + }), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + resumed_response, + WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Pending { + cell_id: "1".to_string(), + content_items: Vec::new(), + pending_tool_call_ids: vec!["tool-3".to_string()], + }) + ); + + let termination = service + .wait(WaitRequest { + cell_id: "1".to_string(), + yield_time_ms: 1, + terminate: true, + }) + .await + .unwrap(); + + assert_eq!( + termination, + WaitOutcome::LiveCell(RuntimeResponse::Terminated { + cell_id: "1".to_string(), + content_items: Vec::new(), + }) + ); + } + + #[tokio::test] + async fn wait_to_pending_returns_after_resumed_runtime_becomes_quiescent_again() { + let service = CodeModeService::new(); + + let initial_response = service + .execute_to_pending(ExecuteRequest { + source: r#" +await new Promise((resolve) => setTimeout(resolve, 60_000)); +text("after"); +await new Promise(() => {}); +"# + .to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + initial_response, + ExecuteToPendingOutcome::Pending { + cell_id: "1".to_string(), + content_items: Vec::new(), + pending_tool_call_ids: Vec::new(), + } + ); + + let runtime_tx = service + .inner + .sessions + .lock() + .await + .get("1") + .unwrap() + .runtime_tx + .clone(); + runtime_tx + .send(RuntimeCommand::TimeoutFired { id: 1 }) + .unwrap(); + + let resumed_response = tokio::time::timeout( + Duration::from_secs(1), + service.wait_to_pending(WaitToPendingRequest { + cell_id: "1".to_string(), + }), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + resumed_response, + WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Pending { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "after".to_string(), + }], + pending_tool_call_ids: Vec::new(), + }) + ); + + let termination = service + .wait(WaitRequest { + cell_id: "1".to_string(), + yield_time_ms: 1, + terminate: true, + }) + .await + .unwrap(); + + assert_eq!( + termination, + WaitOutcome::LiveCell(RuntimeResponse::Terminated { + cell_id: "1".to_string(), + content_items: Vec::new(), + }) + ); + } + + #[tokio::test] + async fn wait_to_pending_returns_completed_after_resumed_runtime_finishes() { + let service = CodeModeService::new(); + + let initial_response = service + .execute_to_pending(ExecuteRequest { + source: r#" +await new Promise((resolve) => setTimeout(resolve, 60_000)); +text("done"); +"# + .to_string(), + yield_time_ms: Some(60_000), + ..execute_request("") + }) + .await + .unwrap(); + + assert_eq!( + initial_response, + ExecuteToPendingOutcome::Pending { + cell_id: "1".to_string(), + content_items: Vec::new(), + pending_tool_call_ids: Vec::new(), + } + ); + + let runtime_tx = service + .inner + .sessions + .lock() + .await + .get("1") + .unwrap() + .runtime_tx + .clone(); + runtime_tx + .send(RuntimeCommand::TimeoutFired { id: 1 }) + .unwrap(); + + let resumed_response = tokio::time::timeout( + Duration::from_secs(1), + service.wait_to_pending(WaitToPendingRequest { + cell_id: "1".to_string(), + }), + ) + .await + .unwrap() + .unwrap(); + + assert_eq!( + resumed_response, + WaitToPendingOutcome::LiveCell(ExecuteToPendingOutcome::Completed( + RuntimeResponse::Result { + cell_id: "1".to_string(), + content_items: vec![FunctionCallOutputContentItem::InputText { + text: "done".to_string(), + }], + stored_values: HashMap::new(), + error_text: None, + } + )) + ); + } + #[tokio::test] async fn v8_console_is_not_exposed_on_global_this() { let service = CodeModeService::new(); @@ -892,13 +1442,14 @@ image({ let (control_tx, control_rx) = mpsc::unbounded_channel(); let (initial_response_tx, initial_response_rx) = oneshot::channel(); let (runtime_event_tx, _runtime_event_rx) = mpsc::unbounded_channel(); - let (runtime_tx, runtime_terminate_handle) = spawn_runtime( + let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = spawn_runtime( ExecuteRequest { source: "await new Promise(() => {})".to_string(), yield_time_ms: None, ..execute_request("") }, runtime_event_tx, + PendingRuntimeMode::Continue, ) .unwrap(); @@ -907,12 +1458,14 @@ image({ SessionControlContext { cell_id: "cell-1".to_string(), runtime_tx: runtime_tx.clone(), + runtime_control_tx, + pending_mode: PendingRuntimeMode::Continue, runtime_terminate_handle, }, event_rx, control_rx, - initial_response_tx, - /*initial_yield_time_ms*/ 60_000, + SessionResponseSender::Runtime(initial_response_tx), + Some(/*initial_yield_time_ms*/ 60_000), )); event_tx.send(RuntimeEvent::Started).unwrap(); diff --git a/codex-rs/codex-api/Cargo.toml b/codex-rs/codex-api/Cargo.toml index 14340af1eb..08f70cf33c 100644 --- a/codex-rs/codex-api/Cargo.toml +++ b/codex-rs/codex-api/Cargo.toml @@ -39,3 +39,6 @@ reqwest = { workspace = true } [lints] workspace = true + +[lib] +doctest = false diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index e2d2ed3c3c..91b251c41f 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -32,6 +32,10 @@ pub struct CompactionInput<'a> { #[serde(skip_serializing_if = "Option::is_none")] pub reasoning: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub service_tier: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + pub prompt_cache_key: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] pub text: Option, } @@ -235,6 +239,11 @@ pub struct ResponseCreateWsRequest { pub client_metadata: Option>, } +#[derive(Debug, Serialize)] +pub struct ResponseProcessedWsRequest { + pub response_id: String, +} + pub fn response_create_client_metadata( client_metadata: Option>, trace: Option<&W3cTraceContext>, @@ -263,6 +272,8 @@ pub fn response_create_client_metadata( pub enum ResponsesWsRequest { #[serde(rename = "response.create")] ResponseCreate(ResponseCreateWsRequest), + #[serde(rename = "response.processed")] + ResponseProcessed(ResponseProcessedWsRequest), } pub fn create_text_param_for_request( diff --git a/codex-rs/codex-api/src/endpoint/responses.rs b/codex-rs/codex-api/src/endpoint/responses.rs index 17b478d1fd..cc1be2846a 100644 --- a/codex-rs/codex-api/src/endpoint/responses.rs +++ b/codex-rs/codex-api/src/endpoint/responses.rs @@ -6,7 +6,7 @@ use crate::error::ApiError; use crate::provider::Provider; use crate::requests::Compression; use crate::requests::attach_item_ids; -use crate::requests::headers::build_conversation_headers; +use crate::requests::headers::build_session_headers; use crate::requests::headers::insert_header; use crate::requests::headers::subagent_header; use crate::sse::spawn_response_stream; @@ -30,7 +30,8 @@ pub struct ResponsesClient { #[derive(Default)] pub struct ResponsesOptions { - pub conversation_id: Option, + pub session_id: Option, + pub thread_id: Option, pub session_source: Option, pub extra_headers: HeaderMap, pub compression: Compression, @@ -72,7 +73,8 @@ impl ResponsesClient { options: ResponsesOptions, ) -> Result { let ResponsesOptions { - conversation_id, + session_id, + thread_id, session_source, extra_headers, compression, @@ -86,10 +88,10 @@ impl ResponsesClient { } let mut headers = extra_headers; - if let Some(ref conv_id) = conversation_id { - insert_header(&mut headers, "x-client-request-id", conv_id); + if let Some(ref thread_id) = thread_id { + insert_header(&mut headers, "x-client-request-id", thread_id); } - headers.extend(build_conversation_headers(conversation_id)); + headers.extend(build_session_headers(session_id, thread_id)); if let Some(subagent) = subagent_header(&session_source) { insert_header(&mut headers, "x-openai-subagent", &subagent); } diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index 4e97ecef9d..c5a682b328 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -1,5 +1,6 @@ use crate::auth::SharedAuthProvider; use crate::common::ResponseEvent; +use crate::common::ResponseProcessedWsRequest; use crate::common::ResponseStream; use crate::common::ResponsesWsRequest; use crate::error::ApiError; @@ -204,6 +205,40 @@ impl ResponsesWebsocketConnection { self.stream.lock().await.is_none() } + #[instrument( + name = "responses_websocket.send_response_processed", + level = "info", + skip_all, + fields(transport = "responses_websocket", api.path = "responses") + )] + #[expect( + clippy::await_holding_invalid_type, + reason = "the guard serializes exclusive use of the websocket while sending a request frame" + )] + pub async fn send_response_processed(&self, response_id: String) -> Result<(), ApiError> { + let request = + ResponsesWsRequest::ResponseProcessed(ResponseProcessedWsRequest { response_id }); + let request_body = serde_json::to_value(&request).map_err(|err| { + ApiError::Stream(format!("failed to encode websocket request: {err}")) + })?; + + let mut guard = self.stream.lock().await; + let Some(ws_stream) = guard.as_mut() else { + return Err(ApiError::Stream( + "websocket connection is closed".to_string(), + )); + }; + + send_websocket_request( + ws_stream, + request_body, + self.idle_timeout, + self.telemetry.as_ref(), + /*connection_reused*/ true, + ) + .await + } + #[instrument( name = "responses_websocket.stream_request", level = "info", @@ -545,31 +580,14 @@ async fn run_websocket_response_stream( connection_reused: bool, ) -> Result<(), ApiError> { let mut last_server_model: Option = None; - let request_text = match serde_json::to_string(&request_body) { - Ok(text) => text, - Err(err) => { - return Err(ApiError::Stream(format!( - "failed to encode websocket request: {err}" - ))); - } - }; - trace!("websocket request: {request_text}"); - - let request_start = Instant::now(); - let result = ws_stream - .send(Message::Text(request_text.into())) - .await - .map_err(|err| ApiError::Stream(format!("failed to send websocket request: {err}"))); - - if let Some(t) = telemetry.as_ref() { - t.on_ws_request( - request_start.elapsed(), - result.as_ref().err(), - connection_reused, - ); - } - - result?; + send_websocket_request( + ws_stream, + request_body, + idle_timeout, + telemetry.as_ref(), + connection_reused, + ) + .await?; loop { let poll_start = Instant::now(); @@ -666,6 +684,47 @@ async fn run_websocket_response_stream( Ok(()) } +async fn send_websocket_request( + ws_stream: &WsStream, + request_body: Value, + idle_timeout: Duration, + telemetry: Option<&Arc>, + connection_reused: bool, +) -> Result<(), ApiError> { + let request_text = match serde_json::to_string(&request_body) { + Ok(text) => text, + Err(err) => { + return Err(ApiError::Stream(format!( + "failed to encode websocket request: {err}" + ))); + } + }; + trace!("websocket request: {request_text}"); + + let request_start = Instant::now(); + let result = tokio::time::timeout( + idle_timeout, + ws_stream.send(Message::Text(request_text.into())), + ) + .await + .map_err(|_| ApiError::Stream("idle timeout sending websocket request".into())) + .and_then(|result| { + result.map_err(|err| ApiError::Stream(format!("failed to send websocket request: {err}"))) + }); + + if let Some(t) = telemetry.as_ref() { + t.on_ws_request( + request_start.elapsed(), + result.as_ref().err(), + connection_reused, + ); + } + + result?; + + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 0b8aee266b..d7017c8d3f 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -10,7 +10,7 @@ pub(crate) mod requests; pub(crate) mod sse; pub(crate) mod telemetry; -pub use crate::requests::headers::build_conversation_headers; +pub use crate::requests::headers::build_session_headers; pub use codex_client::RequestTelemetry; pub use codex_client::ReqwestTransport; pub use codex_client::TransportError; @@ -30,6 +30,7 @@ pub use crate::common::RawMemoryMetadata; pub use crate::common::Reasoning; pub use crate::common::ResponseCreateWsRequest; pub use crate::common::ResponseEvent; +pub use crate::common::ResponseProcessedWsRequest; pub use crate::common::ResponseStream; pub use crate::common::ResponsesApiRequest; pub use crate::common::ResponsesWsRequest; @@ -62,7 +63,6 @@ pub use crate::provider::Provider; pub use crate::provider::RetryConfig; pub use crate::provider::is_azure_responses_provider; pub use crate::requests::Compression; -pub use crate::sse::stream_from_fixture; pub use crate::telemetry::SseTelemetry; pub use crate::telemetry::WebsocketTelemetry; pub use codex_protocol::protocol::RealtimeAudioFrame; diff --git a/codex-rs/codex-api/src/requests/headers.rs b/codex-rs/codex-api/src/requests/headers.rs index d1ab834109..ed5a5ad647 100644 --- a/codex-rs/codex-api/src/requests/headers.rs +++ b/codex-rs/codex-api/src/requests/headers.rs @@ -2,10 +2,15 @@ use codex_protocol::protocol::SessionSource; use http::HeaderMap; use http::HeaderValue; -pub fn build_conversation_headers(conversation_id: Option) -> HeaderMap { +pub fn build_session_headers(session_id: Option, thread_id: Option) -> HeaderMap { let mut headers = HeaderMap::new(); - if let Some(id) = conversation_id { + if let Some(id) = session_id { insert_header(&mut headers, "session_id", &id); + insert_header(&mut headers, "session-id", &id); + } + if let Some(id) = thread_id { + insert_header(&mut headers, "thread_id", &id); + insert_header(&mut headers, "thread-id", &id); } headers } diff --git a/codex-rs/codex-api/src/sse/mod.rs b/codex-rs/codex-api/src/sse/mod.rs index 06b9855890..441078bdcb 100644 --- a/codex-rs/codex-api/src/sse/mod.rs +++ b/codex-rs/codex-api/src/sse/mod.rs @@ -3,4 +3,3 @@ pub(crate) mod responses; pub(crate) use responses::ResponsesStreamEvent; pub(crate) use responses::process_responses_event; pub use responses::spawn_response_stream; -pub use responses::stream_from_fixture; diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 4e949e43ae..c9f35e5a4e 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -5,24 +5,19 @@ use crate::rate_limits::parse_all_rate_limits; use crate::telemetry::SseTelemetry; use codex_client::ByteStream; use codex_client::StreamResponse; -use codex_client::TransportError; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ModelVerification; use codex_protocol::protocol::TokenUsage; use eventsource_stream::Eventsource; use futures::StreamExt; -use futures::TryStreamExt; use serde::Deserialize; use serde_json::Value; -use std::io::BufRead; -use std::path::Path; use std::sync::Arc; use std::sync::OnceLock; use std::time::Duration; use tokio::sync::mpsc; use tokio::time::Instant; use tokio::time::timeout; -use tokio_util::io::ReaderStream; use tracing::debug; use tracing::trace; @@ -31,35 +26,6 @@ const OPENAI_MODEL_HEADER: &str = "openai-model"; const REQUEST_ID_HEADER: &str = "x-request-id"; const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION: &str = "trusted_access_for_cyber"; -/// Streams SSE events from an on-disk fixture for tests. -pub fn stream_from_fixture( - path: impl AsRef, - idle_timeout: Duration, -) -> Result { - let file = - std::fs::File::open(path.as_ref()).map_err(|err| ApiError::Stream(err.to_string()))?; - let mut content = String::new(); - for line in std::io::BufReader::new(file).lines() { - let line = line.map_err(|err| ApiError::Stream(err.to_string()))?; - content.push_str(&line); - content.push_str("\n\n"); - } - - let reader = std::io::Cursor::new(content); - let stream = ReaderStream::new(reader).map_err(|err| TransportError::Network(err.to_string())); - let (tx_event, rx_event) = mpsc::channel::>(1600); - tokio::spawn(process_sse( - Box::pin(stream), - tx_event, - idle_timeout, - /*telemetry*/ None, - )); - Ok(ResponseStream { - rx_event, - upstream_request_id: None, - }) -} - pub fn spawn_response_stream( stream_response: StreamResponse, idle_timeout: Duration, @@ -593,8 +559,10 @@ mod tests { use assert_matches::assert_matches; use bytes::Bytes; use codex_client::StreamResponse; + use codex_client::TransportError; use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseItem; + use futures::TryStreamExt; use futures::stream; use http::HeaderMap; use http::HeaderValue; @@ -603,6 +571,7 @@ mod tests { use serde_json::json; use tokio::sync::mpsc; use tokio_test::io::Builder as IoBuilder; + use tokio_util::io::ReaderStream; async fn collect_events(chunks: &[&[u8]]) -> Vec> { let mut builder = IoBuilder::new(); diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 218a99f9b2..a78073b302 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -444,7 +444,8 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { .stream_request( request, ResponsesOptions { - conversation_id: Some("sess_123".into()), + session_id: Some("sess_123".into()), + thread_id: Some("thread_123".into()), session_source: Some(SessionSource::SubAgent(SubAgentSource::Review)), extra_headers, compression: Compression::None, @@ -461,6 +462,24 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { req.headers.get("session_id").and_then(|v| v.to_str().ok()), Some("sess_123") ); + assert_eq!( + req.headers.get("session-id").and_then(|v| v.to_str().ok()), + Some("sess_123") + ); + assert_eq!( + req.headers.get("thread_id").and_then(|v| v.to_str().ok()), + Some("thread_123") + ); + assert_eq!( + req.headers.get("thread-id").and_then(|v| v.to_str().ok()), + Some("thread_123") + ); + assert_eq!( + req.headers + .get("x-client-request-id") + .and_then(|v| v.to_str().ok()), + Some("thread_123") + ); assert_eq!( req.headers .get("x-openai-subagent") diff --git a/codex-rs/codex-api/tests/models_integration.rs b/codex-rs/codex-api/tests/models_integration.rs index 9f95c9441f..d2b31180b9 100644 --- a/codex-rs/codex-api/tests/models_integration.rs +++ b/codex-rs/codex-api/tests/models_integration.rs @@ -75,6 +75,7 @@ async fn models_client_hits_models_endpoint() { supported_in_api: true, priority: 1, additional_speed_tiers: Vec::new(), + service_tiers: Vec::new(), upgrade: None, base_instructions: "base instructions".to_string(), model_messages: None, diff --git a/codex-rs/codex-backend-openapi-models/Cargo.toml b/codex-rs/codex-backend-openapi-models/Cargo.toml index ed3a1043d6..7baf01935d 100644 --- a/codex-rs/codex-backend-openapi-models/Cargo.toml +++ b/codex-rs/codex-backend-openapi-models/Cargo.toml @@ -7,6 +7,8 @@ license.workspace = true [lib] name = "codex_backend_openapi_models" path = "src/lib.rs" +test = false +doctest = false [lints] workspace = true @@ -20,6 +22,3 @@ workspace = true serde = { version = "1", features = ["derive"] } serde_json = "1" serde_with = "3" - -[package.metadata.cargo-shear] -ignored = ["serde_with"] diff --git a/codex-rs/codex-client/Cargo.toml b/codex-rs/codex-client/Cargo.toml index 2ef31ac826..184505eb55 100644 --- a/codex-rs/codex-client/Cargo.toml +++ b/codex-rs/codex-client/Cargo.toml @@ -12,7 +12,7 @@ futures = { workspace = true } http = { workspace = true } opentelemetry = { workspace = true } rand = { workspace = true } -reqwest = { workspace = true, features = ["json", "stream"] } +reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots", "stream"] } rustls = { workspace = true } rustls-native-certs = { workspace = true } rustls-pki-types = { workspace = true } @@ -32,5 +32,9 @@ workspace = true codex-utils-cargo-bin = { workspace = true } opentelemetry_sdk = { workspace = true } pretty_assertions = { workspace = true } +rcgen = { workspace = true } tempfile = { workspace = true } tracing-subscriber = { workspace = true } + +[lib] +doctest = false diff --git a/codex-rs/codex-client/src/bin/custom_ca_probe.rs b/codex-rs/codex-client/src/bin/custom_ca_probe.rs index 164f1054b4..81f5ba9bc2 100644 --- a/codex-rs/codex-client/src/bin/custom_ca_probe.rs +++ b/codex-rs/codex-client/src/bin/custom_ca_probe.rs @@ -8,22 +8,93 @@ //! - env precedence is respected, //! - multi-cert PEM bundles load, //! - error messages guide users when CA files are invalid. +//! - optional HTTPS probes can complete a request through the constructed client. //! //! The detailed explanation of what "hermetic" means here lives in `codex_client::custom_ca`. //! This binary exists so the tests can exercise //! [`codex_client::build_reqwest_client_for_subprocess_tests`] in a separate process without //! duplicating client-construction logic. +use std::env; use std::process; +use std::time::Duration; + +const PROBE_TLS13_ENV: &str = "CODEX_CUSTOM_CA_PROBE_TLS13"; +const PROBE_PROXY_ENV: &str = "CODEX_CUSTOM_CA_PROBE_PROXY"; +const PROBE_URL_ENV: &str = "CODEX_CUSTOM_CA_PROBE_URL"; fn main() { - match codex_client::build_reqwest_client_for_subprocess_tests(reqwest::Client::builder()) { - Ok(_) => { - println!("ok"); + let runtime = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(runtime) => runtime, + Err(error) => { + eprintln!("failed to create probe runtime: {error}"); + process::exit(1); } + }; + + match runtime.block_on(run_probe()) { + Ok(()) => println!("ok"), Err(error) => { eprintln!("{error}"); process::exit(1); } } } + +async fn run_probe() -> Result<(), String> { + let proxy_url = env::var(PROBE_PROXY_ENV).ok(); + let target_url = env::var(PROBE_URL_ENV).ok(); + let mut builder = reqwest::Client::builder(); + if target_url.is_some() { + builder = builder.timeout(Duration::from_secs(5)); + } + if env::var_os(PROBE_TLS13_ENV).is_some() { + builder = builder.min_tls_version(reqwest::tls::Version::TLS_1_3); + } + + let client = build_probe_client(builder, proxy_url.as_deref())?; + if let Some(url) = target_url { + post_probe_request(&client, &url).await?; + } + Ok(()) +} + +fn build_probe_client( + builder: reqwest::ClientBuilder, + proxy_url: Option<&str>, +) -> Result { + if let Some(proxy_url) = proxy_url { + let proxy = reqwest::Proxy::https(proxy_url) + .map_err(|error| format!("failed to configure probe proxy {proxy_url}: {error}"))?; + return codex_client::build_reqwest_client_with_custom_ca(builder.proxy(proxy)) + .map_err(|error| error.to_string()); + } + + codex_client::build_reqwest_client_for_subprocess_tests(builder) + .map_err(|error| error.to_string()) +} + +async fn post_probe_request(client: &reqwest::Client, url: &str) -> Result<(), String> { + let response = client + .post(url) + .header("Content-Type", "application/x-www-form-urlencoded") + .body("grant_type=authorization_code&code=test") + .send() + .await + .map_err(|error| format!("probe request failed: {error:?}"))?; + let status = response.status(); + let body = response + .text() + .await + .map_err(|error| format!("failed to read probe response body: {error}"))?; + if !status.is_success() { + return Err(format!("probe request returned {status}: {body}")); + } + if body != "ok" { + return Err(format!("probe response body mismatch: {body}")); + } + Ok(()) +} diff --git a/codex-rs/codex-client/src/custom_ca.rs b/codex-rs/codex-client/src/custom_ca.rs index 7e0a6dbee1..7a8b2f27bd 100644 --- a/codex-rs/codex-client/src/custom_ca.rs +++ b/codex-rs/codex-client/src/custom_ca.rs @@ -14,10 +14,9 @@ //! `TRUSTED CERTIFICATE` labels and bundles that also contain CRLs //! - return user-facing errors that explain how to fix misconfigured CA files //! -//! It does not validate certificate chains or perform a handshake in tests. Its contract is -//! narrower: produce a transport configuration whose root store contains every parseable -//! certificate block from the configured PEM bundle, or fail early with a precise error before -//! the caller starts network traffic. +//! Its production contract is narrow: produce a transport configuration whose root store contains +//! every parseable certificate block from the configured PEM bundle, or fail early with a precise +//! error before the caller starts network traffic. //! //! In this module's test setup, a hermetic test is one whose result depends only on the CA file //! and environment variables that the test chose for itself. That matters here because the normal @@ -36,7 +35,8 @@ //! - unit tests in this module cover env-selection logic without constructing a real client //! - subprocess integration tests under `tests/` cover real client construction through //! [`build_reqwest_client_for_subprocess_tests`], which disables reqwest proxy autodetection so -//! the tests can observe custom-CA success and failure directly +//! the tests can observe custom-CA success and failure directly, including one TLS handshake +//! through a local HTTPS server //! - those subprocess tests also scrub inherited CA environment variables before launch so their //! result depends only on the test fixtures and env vars set by the test itself @@ -266,12 +266,21 @@ fn maybe_build_rustls_client_config_with_env( /// This exists so tests can exercise precedence behavior deterministically without mutating the /// real process environment. It selects the CA bundle, delegates file parsing to /// [`ConfiguredCaBundle::load_certificates`], preserves the caller's chosen `reqwest` builder -/// configuration, and finally registers each parsed certificate with that builder. +/// configuration, forces rustls when a custom CA is configured, and finally registers each parsed +/// certificate with that builder. fn build_reqwest_client_with_env( env_source: &dyn EnvSource, mut builder: reqwest::ClientBuilder, ) -> Result { if let Some(bundle) = env_source.configured_ca_bundle() { + ensure_rustls_crypto_provider(); + info!( + source_env = bundle.source_env, + ca_path = %bundle.path.display(), + "building HTTP client with rustls backend for custom CA bundle" + ); + builder = builder.use_rustls_tls(); + let certificates = bundle.load_certificates()?; for (idx, cert) in certificates.iter().enumerate() { diff --git a/codex-rs/codex-client/tests/ca_env.rs b/codex-rs/codex-client/tests/ca_env.rs index 6992ea7326..6a3a0e0caf 100644 --- a/codex-rs/codex-client/tests/ca_env.rs +++ b/codex-rs/codex-client/tests/ca_env.rs @@ -4,24 +4,83 @@ //! `build_reqwest_client_for_subprocess_tests` instead of calling the helper in-process. The //! detailed explanation of what "hermetic" means here lives in `codex_client::custom_ca`; these //! tests add the process-level half of that contract by scrubbing inherited CA environment -//! variables before each subprocess launch. They still stop at client construction: the -//! assertions here cover CA file selection, PEM parsing, and user-facing errors, not a full TLS -//! handshake. +//! variables before each subprocess launch. Most assertions here cover CA file selection, PEM +//! parsing, and user-facing errors. The HTTPS probes go further and perform real POSTs against +//! locally generated certificates, including through a TLS-intercepting CONNECT proxy. use codex_utils_cargo_bin::cargo_bin; +use rcgen::BasicConstraints; +use rcgen::CertificateParams; +use rcgen::CertifiedIssuer; +use rcgen::DistinguishedName; +use rcgen::DnType; +use rcgen::ExtendedKeyUsagePurpose; +use rcgen::IsCa; +use rcgen::KeyPair; +use rcgen::KeyUsagePurpose; +use rcgen::PKCS_ECDSA_P256_SHA256; +use rustls_pki_types::CertificateDer; +use rustls_pki_types::PrivateKeyDer; use std::fs; +use std::io; +use std::io::Read; +use std::io::Write; +use std::net::TcpListener; +use std::net::TcpStream; use std::path::Path; +use std::path::PathBuf; use std::process::Command; +use std::sync::Arc; +use std::sync::mpsc; +use std::thread; +use std::time::Duration; +use std::time::Instant; use tempfile::TempDir; const CODEX_CA_CERT_ENV: &str = "CODEX_CA_CERTIFICATE"; +const PROBE_PROXY_ENV: &str = "CODEX_CUSTOM_CA_PROBE_PROXY"; +const PROBE_TLS13_ENV: &str = "CODEX_CUSTOM_CA_PROBE_TLS13"; +const PROBE_URL_ENV: &str = "CODEX_CUSTOM_CA_PROBE_URL"; const SSL_CERT_FILE_ENV: &str = "SSL_CERT_FILE"; +const PROXY_ENV_VARS: &[&str] = &[ + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + "NO_PROXY", + "no_proxy", +]; const TEST_CERT_1: &str = include_str!("fixtures/test-ca.pem"); const TEST_CERT_2: &str = include_str!("fixtures/test-intermediate.pem"); const TRUSTED_TEST_CERT: &str = include_str!("fixtures/test-ca-trusted.pem"); -fn write_cert_file(temp_dir: &TempDir, name: &str, contents: &str) -> std::path::PathBuf { +struct Tls13Material { + ca_cert_pem: String, + server_cert: CertificateDer<'static>, + server_key: PrivateKeyDer<'static>, +} + +struct Tls13TestServer { + ca_cert_pem: String, + request_rx: mpsc::Receiver>, + url: String, +} + +struct PlainHttpOrigin { + request_rx: mpsc::Receiver>, + url: String, +} + +struct TlsInterceptingProxy { + ca_cert_pem: String, + request_rx: mpsc::Receiver>, + url: String, +} + +fn write_cert_file(temp_dir: &TempDir, name: &str, contents: &str) -> PathBuf { let path = temp_dir.path().join(name); fs::write(&path, contents).unwrap_or_else(|error| { panic!("write cert fixture failed for {}: {error}", path.display()) @@ -29,7 +88,7 @@ fn write_cert_file(temp_dir: &TempDir, name: &str, contents: &str) -> std::path: path } -fn run_probe(envs: &[(&str, &Path)]) -> std::process::Output { +fn probe_command() -> Command { let mut cmd = Command::new( cargo_bin("custom_ca_probe") .unwrap_or_else(|error| panic!("failed to locate custom_ca_probe: {error}")), @@ -37,7 +96,18 @@ fn run_probe(envs: &[(&str, &Path)]) -> std::process::Output { // `Command` inherits the parent environment by default, so scrub CA-related variables first or // these tests can accidentally pass/fail based on the developer shell or CI runner. cmd.env_remove(CODEX_CA_CERT_ENV); + cmd.env_remove(PROBE_PROXY_ENV); + cmd.env_remove(PROBE_TLS13_ENV); + cmd.env_remove(PROBE_URL_ENV); cmd.env_remove(SSL_CERT_FILE_ENV); + for env_var in PROXY_ENV_VARS { + cmd.env_remove(env_var); + } + cmd +} + +fn run_probe(envs: &[(&str, &Path)]) -> std::process::Output { + let mut cmd = probe_command(); for (key, value) in envs { cmd.env(key, value); } @@ -45,6 +115,286 @@ fn run_probe(envs: &[(&str, &Path)]) -> std::process::Output { .unwrap_or_else(|error| panic!("failed to run custom_ca_probe: {error}")) } +fn run_probe_posting_to_tls13_server(envs: &[(&str, &Path)], url: &str) -> std::process::Output { + let mut cmd = probe_command(); + for (key, value) in envs { + cmd.env(key, value); + } + cmd.env(PROBE_TLS13_ENV, "1"); + cmd.env(PROBE_URL_ENV, url); + cmd.output() + .unwrap_or_else(|error| panic!("failed to run custom_ca_probe: {error}")) +} + +fn run_probe_posting_through_tls_intercepting_proxy( + envs: &[(&str, &Path)], + url: &str, + proxy_url: &str, +) -> std::process::Output { + let mut cmd = probe_command(); + for (key, value) in envs { + cmd.env(key, value); + } + cmd.env(PROBE_PROXY_ENV, proxy_url); + cmd.env(PROBE_TLS13_ENV, "1"); + cmd.env(PROBE_URL_ENV, url); + cmd.output() + .unwrap_or_else(|error| panic!("failed to run custom_ca_probe: {error}")) +} + +fn spawn_tls13_test_server() -> Tls13TestServer { + codex_utils_rustls_provider::ensure_rustls_crypto_provider(); + let material = generate_tls13_material(); + let listener = TcpListener::bind(("127.0.0.1", 0)) + .unwrap_or_else(|error| panic!("bind TLS test server: {error}")); + listener + .set_nonblocking(true) + .unwrap_or_else(|error| panic!("set TLS test server nonblocking: {error}")); + let port = listener + .local_addr() + .unwrap_or_else(|error| panic!("TLS test server addr: {error}")) + .port(); + let config = Arc::new( + rustls::ServerConfig::builder_with_protocol_versions(&[&rustls::version::TLS13]) + .with_no_client_auth() + .with_single_cert(vec![material.server_cert], material.server_key) + .unwrap_or_else(|error| panic!("TLS 1.3 server config: {error}")), + ); + let (request_tx, request_rx) = mpsc::channel(); + + thread::spawn(move || { + let result = accept_tls13_request(listener, config); + let _ = request_tx.send(result.map_err(|error| error.to_string())); + }); + + Tls13TestServer { + ca_cert_pem: material.ca_cert_pem, + request_rx, + url: format!("https://127.0.0.1:{port}/oauth/token"), + } +} + +fn spawn_plain_http_origin() -> PlainHttpOrigin { + let listener = TcpListener::bind(("127.0.0.1", 0)) + .unwrap_or_else(|error| panic!("bind plain HTTP origin: {error}")); + listener + .set_nonblocking(true) + .unwrap_or_else(|error| panic!("set plain HTTP origin nonblocking: {error}")); + let port = listener + .local_addr() + .unwrap_or_else(|error| panic!("plain HTTP origin addr: {error}")) + .port(); + let (request_tx, request_rx) = mpsc::channel(); + + thread::spawn(move || { + let result = accept_plain_http_origin_request(listener); + let _ = request_tx.send(result.map_err(|error| error.to_string())); + }); + + PlainHttpOrigin { + request_rx, + url: format!("https://127.0.0.1:{port}/oauth/token"), + } +} + +fn spawn_tls_intercepting_proxy() -> TlsInterceptingProxy { + codex_utils_rustls_provider::ensure_rustls_crypto_provider(); + let material = generate_tls13_material(); + let listener = TcpListener::bind(("127.0.0.1", 0)) + .unwrap_or_else(|error| panic!("bind TLS intercepting proxy: {error}")); + listener + .set_nonblocking(true) + .unwrap_or_else(|error| panic!("set TLS intercepting proxy nonblocking: {error}")); + let port = listener + .local_addr() + .unwrap_or_else(|error| panic!("TLS intercepting proxy addr: {error}")) + .port(); + let config = Arc::new( + rustls::ServerConfig::builder_with_protocol_versions(&[&rustls::version::TLS13]) + .with_no_client_auth() + .with_single_cert(vec![material.server_cert], material.server_key) + .unwrap_or_else(|error| panic!("TLS intercepting proxy config: {error}")), + ); + let (request_tx, request_rx) = mpsc::channel(); + + thread::spawn(move || { + let result = accept_tls_intercepting_proxy_request(listener, config); + let _ = request_tx.send(result.map_err(|error| error.to_string())); + }); + + TlsInterceptingProxy { + ca_cert_pem: material.ca_cert_pem, + request_rx, + url: format!("http://127.0.0.1:{port}"), + } +} + +fn generate_tls13_material() -> Tls13Material { + let mut ca_params = CertificateParams::default(); + ca_params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + ca_params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign]; + let mut ca_distinguished_name = DistinguishedName::new(); + ca_distinguished_name.push(DnType::CommonName, "codex test CA"); + ca_params.distinguished_name = ca_distinguished_name; + let ca_key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256) + .unwrap_or_else(|error| panic!("generate test CA key pair: {error}")); + let ca = CertifiedIssuer::self_signed(ca_params, ca_key_pair) + .unwrap_or_else(|error| panic!("generate test CA certificate: {error}")); + + let mut server_params = + CertificateParams::new(vec!["localhost".to_string(), "127.0.0.1".to_string()]) + .unwrap_or_else(|error| panic!("create test server certificate params: {error}")); + server_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + server_params.key_usages = vec![ + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::KeyEncipherment, + ]; + let server_key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256) + .unwrap_or_else(|error| panic!("generate test server key pair: {error}")); + let server_cert = server_params + .signed_by(&server_key_pair, &ca) + .unwrap_or_else(|error| panic!("generate test server certificate: {error}")); + + Tls13Material { + ca_cert_pem: ca.pem(), + server_cert: server_cert.der().clone(), + server_key: PrivateKeyDer::from(server_key_pair), + } +} + +fn accept_plain_http_origin_request(listener: TcpListener) -> io::Result { + let mut stream = accept_with_timeout(listener, Duration::from_secs(5))?; + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + + let request = read_http_message(&mut stream)?; + stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")?; + stream.flush()?; + Ok(request) +} + +fn accept_tls13_request( + listener: TcpListener, + config: Arc, +) -> io::Result { + let stream = accept_with_timeout(listener, Duration::from_secs(5))?; + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + + let connection = rustls::ServerConnection::new(config).map_err(io::Error::other)?; + let mut tls = rustls::StreamOwned::new(connection, stream); + let request = read_http_message(&mut tls)?; + tls.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")?; + tls.flush()?; + Ok(request) +} + +fn accept_tls_intercepting_proxy_request( + listener: TcpListener, + config: Arc, +) -> io::Result { + let mut stream = accept_with_timeout(listener, Duration::from_secs(5))?; + stream.set_nonblocking(false)?; + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + + let connect_request = read_http_message(&mut stream)?; + let origin_authority = connect_authority_from_request(&connect_request)?; + stream.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")?; + stream.flush()?; + + let connection = rustls::ServerConnection::new(config).map_err(io::Error::other)?; + let mut tls = rustls::StreamOwned::new(connection, stream); + let request = read_http_message(&mut tls)?; + + let mut origin = TcpStream::connect(origin_authority.as_str())?; + origin.set_read_timeout(Some(Duration::from_secs(5)))?; + origin.set_write_timeout(Some(Duration::from_secs(5)))?; + origin.write_all(request.as_bytes())?; + origin.flush()?; + let response = read_http_message(&mut origin)?; + + tls.write_all(response.as_bytes())?; + tls.flush()?; + Ok(request) +} + +fn connect_authority_from_request(request: &str) -> io::Result { + let request_line = request + .lines() + .next() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "empty CONNECT request"))?; + let mut parts = request_line.split_whitespace(); + match (parts.next(), parts.next(), parts.next()) { + (Some("CONNECT"), Some(authority), Some(_version)) => Ok(authority.to_string()), + _ => Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("invalid CONNECT request line: {request_line}"), + )), + } +} + +fn accept_with_timeout(listener: TcpListener, timeout: Duration) -> io::Result { + let deadline = Instant::now() + timeout; + loop { + match listener.accept() { + Ok((stream, _)) => return Ok(stream), + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "timed out waiting for TLS test client", + )); + } + thread::sleep(Duration::from_millis(10)); + } + Err(error) => return Err(error), + } + } +} + +fn read_http_message(stream: &mut impl Read) -> io::Result { + let mut buffer = Vec::new(); + let mut chunk = [0; 1024]; + loop { + let bytes_read = stream.read(&mut chunk)?; + if bytes_read == 0 { + break; + } + buffer.extend_from_slice(&chunk[..bytes_read]); + if let Some(header_end) = buffer.windows(4).position(|window| window == b"\r\n\r\n") { + let body_start = header_end + 4; + let headers = String::from_utf8_lossy(&buffer[..body_start]); + let content_length = headers + .lines() + .filter_map(|line| line.split_once(':')) + .find_map(|(name, value)| { + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + if buffer.len() >= body_start + content_length { + break; + } + } + } + Ok(String::from_utf8_lossy(&buffer).into_owned()) +} + +fn assert_token_exchange_request(request: &str) { + assert!( + request.starts_with("POST /oauth/token HTTP/1.1"), + "unexpected request:\n{request}" + ); + assert!( + request.contains("grant_type=authorization_code&code=test"), + "unexpected request body:\n{request}" + ); +} + #[test] fn uses_codex_ca_cert_env() { let temp_dir = TempDir::new().expect("tempdir"); @@ -90,6 +440,59 @@ fn handles_multi_certificate_bundle() { assert!(output.status.success()); } +#[test] +fn posts_to_tls13_server_using_custom_ca_bundle() { + let temp_dir = TempDir::new().expect("tempdir"); + let server = spawn_tls13_test_server(); + let cert_path = write_cert_file(&temp_dir, "tls-ca.pem", &server.ca_cert_pem); + + let output = + run_probe_posting_to_tls13_server(&[(CODEX_CA_CERT_ENV, cert_path.as_path())], &server.url); + let server_result = server.request_rx.recv_timeout(Duration::from_secs(5)); + + assert!( + output.status.success(), + "custom_ca_probe failed\nstdout:\n{}\nstderr:\n{}\nserver:\n{server_result:?}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let request = server_result + .expect("TLS test server should report a request") + .expect("TLS test server should accept the probe request"); + assert_token_exchange_request(&request); +} + +#[test] +fn posts_to_token_origin_through_tls_intercepting_proxy_with_custom_ca_bundle() { + let temp_dir = TempDir::new().expect("tempdir"); + let origin = spawn_plain_http_origin(); + let proxy = spawn_tls_intercepting_proxy(); + let cert_path = write_cert_file(&temp_dir, "proxy-ca.pem", &proxy.ca_cert_pem); + + let output = run_probe_posting_through_tls_intercepting_proxy( + &[(CODEX_CA_CERT_ENV, cert_path.as_path())], + &origin.url, + &proxy.url, + ); + let proxy_result = proxy.request_rx.recv_timeout(Duration::from_secs(5)); + let origin_result = origin.request_rx.recv_timeout(Duration::from_secs(5)); + + assert!( + output.status.success(), + "custom_ca_probe failed\nstdout:\n{}\nstderr:\n{}\nproxy:\n{proxy_result:?}\norigin:\n{origin_result:?}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let proxy_request = proxy_result + .expect("TLS intercepting proxy should report a request") + .expect("TLS intercepting proxy should accept the probe request"); + let origin_request = origin_result + .expect("plain HTTP origin should report a request") + .expect("plain HTTP origin should accept the forwarded request"); + assert_token_exchange_request(&proxy_request); + assert_token_exchange_request(&origin_request); +} + #[test] fn rejects_empty_pem_file_with_hint() { let temp_dir = TempDir::new().expect("tempdir"); diff --git a/codex-rs/codex-experimental-api-macros/Cargo.toml b/codex-rs/codex-experimental-api-macros/Cargo.toml index cef1ec243f..2e148a21d7 100644 --- a/codex-rs/codex-experimental-api-macros/Cargo.toml +++ b/codex-rs/codex-experimental-api-macros/Cargo.toml @@ -6,6 +6,8 @@ license.workspace = true [lib] proc-macro = true +test = false +doctest = false [dependencies] proc-macro2 = "1" diff --git a/codex-rs/codex-mcp/Cargo.toml b/codex-rs/codex-mcp/Cargo.toml index c3061adca9..d7634f0aae 100644 --- a/codex-rs/codex-mcp/Cargo.toml +++ b/codex-rs/codex-mcp/Cargo.toml @@ -7,6 +7,7 @@ version.workspace = true [lib] name = "codex_mcp" path = "src/lib.rs" +doctest = false [lints] workspace = true @@ -32,7 +33,7 @@ serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha1 = { workspace = true } thiserror = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tokio = { workspace = true, features = ["io-util", "macros", "rt-multi-thread"] } tokio-util = { workspace = true, features = ["rt"] } tracing = { workspace = true } url = { workspace = true } diff --git a/codex-rs/codex-mcp/src/auth_elicitation.rs b/codex-rs/codex-mcp/src/auth_elicitation.rs new file mode 100644 index 0000000000..77c7b78c55 --- /dev/null +++ b/codex-rs/codex-mcp/src/auth_elicitation.rs @@ -0,0 +1,347 @@ +//! Auth elicitation helpers. +//! +//! This module owns protocol-neutral auth elicitation parsing and payload shaping. +//! Session orchestration stays in `codex-core`. + +use codex_protocol::mcp::CallToolResult; +use serde::Serialize; + +pub const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps"; +pub const CONNECTOR_AUTH_FAILURE_META_KEY: &str = "connector_auth_failure"; +pub const CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: &str = "is_auth_failure"; +pub const CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: &str = "auth_reason"; +pub const CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: &str = "connector_id"; +pub const CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: &str = "link_id"; +pub const CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: &str = "error_code"; +pub const CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: &str = "error_http_status_code"; +pub const CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: &str = "error_action"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CodexAppsConnectorAuthFailure { + pub connector_id: String, + pub connector_name: String, + pub install_url: String, + pub auth_reason: Option, + pub link_id: Option, + pub error_code: Option, + pub error_http_status_code: Option, + pub error_action: Option, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CodexAppsAuthElicitation { + pub meta: serde_json::Value, + pub message: String, + pub url: String, + pub elicitation_id: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub struct CodexAppsAuthElicitationPlan { + pub auth_failure: CodexAppsConnectorAuthFailure, + pub elicitation: CodexAppsAuthElicitation, +} + +#[derive(Serialize)] +struct CodexAppsConnectorAuthFailureMeta<'a> { + is_auth_failure: bool, + connector_id: &'a str, + connector_name: &'a str, + install_url: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + auth_reason: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + link_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + error_code: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + error_http_status_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error_action: Option<&'a str>, +} + +pub fn connector_auth_failure_from_tool_result( + result: &CallToolResult, + connector_id: Option<&str>, + connector_name: Option<&str>, + install_url: Option, +) -> Option { + if result.is_error != Some(true) { + return None; + } + + let auth_failure = result + .meta + .as_ref()? + .as_object()? + .get(MCP_TOOL_CODEX_APPS_META_KEY)? + .as_object()? + .get(CONNECTOR_AUTH_FAILURE_META_KEY)? + .as_object()?; + if auth_failure + .get(CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY) + .and_then(serde_json::Value::as_bool) + != Some(true) + { + return None; + } + + let connector_id = connector_id + .map(str::trim) + .filter(|connector_id| !connector_id.is_empty())?; + if let Some(auth_failure_connector_id) = + string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY) + && auth_failure_connector_id != connector_id + { + return None; + } + let connector_name = connector_name + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or(connector_id) + .to_string(); + + Some(CodexAppsConnectorAuthFailure { + connector_id: connector_id.to_string(), + connector_name, + install_url: install_url?, + auth_reason: string_auth_failure_field( + auth_failure, + CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY, + ), + link_id: string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_LINK_ID_KEY), + error_code: string_auth_failure_field(auth_failure, CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY), + error_http_status_code: auth_failure + .get(CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY) + .and_then(serde_json::Value::as_i64), + error_action: string_auth_failure_field( + auth_failure, + CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY, + ), + }) +} + +pub fn build_auth_elicitation_plan( + call_id: &str, + result: &CallToolResult, + connector_id: Option<&str>, + connector_name: Option<&str>, + install_url: Option, +) -> Option { + let auth_failure = + connector_auth_failure_from_tool_result(result, connector_id, connector_name, install_url)?; + let elicitation = build_auth_elicitation(call_id, &auth_failure); + Some(CodexAppsAuthElicitationPlan { + auth_failure, + elicitation, + }) +} + +pub fn build_auth_elicitation( + call_id: &str, + auth_failure: &CodexAppsConnectorAuthFailure, +) -> CodexAppsAuthElicitation { + CodexAppsAuthElicitation { + meta: serde_json::json!({ + MCP_TOOL_CODEX_APPS_META_KEY: { + CONNECTOR_AUTH_FAILURE_META_KEY: CodexAppsConnectorAuthFailureMeta { + is_auth_failure: true, + connector_id: &auth_failure.connector_id, + connector_name: &auth_failure.connector_name, + install_url: &auth_failure.install_url, + auth_reason: auth_failure.auth_reason.as_deref(), + link_id: auth_failure.link_id.as_deref(), + error_code: auth_failure.error_code.as_deref(), + error_http_status_code: auth_failure.error_http_status_code, + error_action: auth_failure.error_action.as_deref(), + }, + }, + }), + message: auth_elicitation_message(auth_failure), + url: auth_failure.install_url.clone(), + elicitation_id: auth_elicitation_id(call_id), + } +} + +pub fn auth_elicitation_completed_result( + auth_failure: &CodexAppsConnectorAuthFailure, + meta: Option, +) -> CallToolResult { + CallToolResult { + content: vec![serde_json::json!({ + "type": "text", + "text": format!( + "Authentication for {} was requested and accepted. Retry this tool call now.", + auth_failure.connector_name + ), + })], + structured_content: None, + is_error: Some(true), + meta, + } +} + +pub fn auth_elicitation_id(call_id: &str) -> String { + format!("codex_apps_auth_{call_id}") +} + +fn string_auth_failure_field( + auth_failure: &serde_json::Map, + key: &str, +) -> Option { + auth_failure + .get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +fn auth_elicitation_message(auth_failure: &CodexAppsConnectorAuthFailure) -> String { + match auth_failure.auth_reason.as_deref() { + Some("oauth_upgrade_required") => format!( + "Reconnect {} on ChatGPT to grant the permissions needed for this request.", + auth_failure.connector_name + ), + Some("reauthentication_required") => format!( + "Reconnect {} on ChatGPT to restore access for this request.", + auth_failure.connector_name + ), + Some("missing_link") => format!( + "Sign in to {} on ChatGPT to use it in Codex.", + auth_failure.connector_name + ), + _ => format!( + "Sign in to {} on ChatGPT to continue.", + auth_failure.connector_name + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + fn auth_failure_result() -> CallToolResult { + CallToolResult { + content: vec![serde_json::json!({ + "type": "text", + "text": "Connector reauthentication required", + })], + structured_content: None, + is_error: Some(true), + meta: Some(serde_json::json!({ + MCP_TOOL_CODEX_APPS_META_KEY: { + CONNECTOR_AUTH_FAILURE_META_KEY: { + CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: true, + CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: "reauthentication_required", + CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: "connector_calendar", + "connector_name": "Untrusted Calendar", + CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: "link_123", + CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: "UNAUTHORIZED", + CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: 401, + CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: "TRIGGER_REAUTHENTICATION", + }, + }, + })), + } + } + + #[test] + fn parses_auth_failure_from_trusted_connector_metadata() { + assert_eq!( + connector_auth_failure_from_tool_result( + &auth_failure_result(), + Some("connector_calendar"), + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ), + Some(CodexAppsConnectorAuthFailure { + connector_id: "connector_calendar".to_string(), + connector_name: "Google Calendar".to_string(), + install_url: "https://chatgpt.com/apps/google-calendar/connector_calendar" + .to_string(), + auth_reason: Some("reauthentication_required".to_string()), + link_id: Some("link_123".to_string()), + error_code: Some("UNAUTHORIZED".to_string()), + error_http_status_code: Some(401), + error_action: Some("TRIGGER_REAUTHENTICATION".to_string()), + }) + ); + } + + #[test] + fn rejects_missing_or_mismatched_connector_ids() { + assert_eq!( + connector_auth_failure_from_tool_result( + &auth_failure_result(), + /*connector_id*/ None, + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ), + None + ); + assert_eq!( + connector_auth_failure_from_tool_result( + &auth_failure_result(), + Some("connector_drive"), + Some("Google Drive"), + Some("https://chatgpt.com/apps/google-drive/connector_drive".to_string()), + ), + None + ); + } + + #[test] + fn builds_url_elicitation_payload() { + let auth_failure = connector_auth_failure_from_tool_result( + &auth_failure_result(), + Some("connector_calendar"), + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ) + .expect("auth failure"); + + assert_eq!( + build_auth_elicitation("call_123", &auth_failure), + CodexAppsAuthElicitation { + meta: serde_json::json!({ + MCP_TOOL_CODEX_APPS_META_KEY: { + CONNECTOR_AUTH_FAILURE_META_KEY: { + CONNECTOR_AUTH_FAILURE_IS_AUTH_FAILURE_KEY: true, + CONNECTOR_AUTH_FAILURE_CONNECTOR_ID_KEY: "connector_calendar", + "connector_name": "Google Calendar", + "install_url": + "https://chatgpt.com/apps/google-calendar/connector_calendar", + CONNECTOR_AUTH_FAILURE_AUTH_REASON_KEY: "reauthentication_required", + CONNECTOR_AUTH_FAILURE_LINK_ID_KEY: "link_123", + CONNECTOR_AUTH_FAILURE_ERROR_CODE_KEY: "UNAUTHORIZED", + CONNECTOR_AUTH_FAILURE_ERROR_HTTP_STATUS_CODE_KEY: 401, + CONNECTOR_AUTH_FAILURE_ERROR_ACTION_KEY: "TRIGGER_REAUTHENTICATION", + }, + }, + }), + message: "Reconnect Google Calendar on ChatGPT to restore access for this request." + .to_string(), + url: "https://chatgpt.com/apps/google-calendar/connector_calendar".to_string(), + elicitation_id: "codex_apps_auth_call_123".to_string(), + } + ); + } + + #[test] + fn builds_auth_elicitation_plan() { + let plan = build_auth_elicitation_plan( + "call_123", + &auth_failure_result(), + Some("connector_calendar"), + Some("Google Calendar"), + Some("https://chatgpt.com/apps/google-calendar/connector_calendar".to_string()), + ) + .expect("auth elicitation plan"); + + assert_eq!(plan.auth_failure.connector_name, "Google Calendar"); + assert_eq!(plan.elicitation.elicitation_id, "codex_apps_auth_call_123"); + } +} diff --git a/codex-rs/codex-mcp/src/codex_apps.rs b/codex-rs/codex-mcp/src/codex_apps.rs index 0a7981fb0d..772f3fc42f 100644 --- a/codex-rs/codex-mcp/src/codex_apps.rs +++ b/codex-rs/codex-mcp/src/codex_apps.rs @@ -1,11 +1,10 @@ -//! Codex Apps support for the built-in apps MCP server. +//! Codex Apps support for the host-owned apps MCP server. //! //! This module owns the pieces that are unique to ChatGPT-hosted app //! connectors: cache scoping by authenticated user, disk cache reads/writes, //! connector allow-list filtering, and the normalization that turns app //! connector/tool metadata into model-visible MCP callable names. -use std::collections::HashMap; use std::path::PathBuf; use std::time::Instant; @@ -38,16 +37,6 @@ pub fn codex_apps_tools_cache_key(auth: Option<&CodexAuth>) -> CodexAppsToolsCac } } -pub fn filter_non_codex_apps_mcp_tools_only( - mcp_tools: &HashMap, -) -> HashMap { - mcp_tools - .iter() - .filter(|(_, tool)| tool.server_name != CODEX_APPS_MCP_SERVER_NAME) - .map(|(name, tool)| (name.clone(), tool.clone())) - .collect() -} - #[derive(Clone)] pub(crate) struct CodexAppsToolsCacheContext { pub(crate) codex_home: PathBuf, diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 483a82796a..c5593a5598 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -17,6 +17,7 @@ use crate::codex_apps::CodexAppsToolsCacheContext; use crate::codex_apps::CodexAppsToolsCacheKey; use crate::codex_apps::write_cached_codex_apps_tools_if_needed; use crate::elicitation::ElicitationRequestManager; +use crate::elicitation::ElicitationReviewerHandle; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::ToolPluginProvenance; use crate::rmcp_client::AsyncManagedClient; @@ -28,20 +29,20 @@ use crate::rmcp_client::StartupOutcomeError; use crate::rmcp_client::list_tools_for_client_uncached; use crate::runtime::McpRuntimeEnvironment; use crate::runtime::emit_duration; +use crate::server::EffectiveMcpServer; +use crate::server::McpServerMetadata; use crate::tools::ToolInfo; use crate::tools::filter_tools; -use crate::tools::qualify_tools; +use crate::tools::normalize_tools_for_model; use crate::tools::tool_with_model_visible_input_schema; use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use async_channel::Sender; use codex_config::Constrained; -use codex_config::McpServerConfig; use codex_config::McpServerTransportConfig; use codex_config::types::OAuthCredentialsStoreMode; use codex_login::CodexAuth; -use codex_protocol::ToolName; use codex_protocol::mcp::CallToolResult; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; @@ -52,6 +53,7 @@ use codex_protocol::protocol::McpStartupFailure; use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpStartupUpdateEvent; use codex_rmcp_client::ElicitationResponse; +use rmcp::model::ElicitationCapability; use rmcp::model::ListResourceTemplatesResult; use rmcp::model::ListResourcesResult; use rmcp::model::PaginatedRequestParams; @@ -64,12 +66,12 @@ use tokio::task::JoinSet; use tokio_util::sync::CancellationToken; use tracing::instrument; use tracing::warn; -use url::Url; /// A thin wrapper around a set of running [`RmcpClient`] instances. pub struct McpConnectionManager { clients: HashMap, - server_origins: HashMap, + server_metadata: HashMap, + host_owned_codex_apps_enabled: bool, elicitation_requests: ElicitationRequestManager, startup_cancellation_token: CancellationToken, } @@ -81,10 +83,12 @@ impl McpConnectionManager { ) -> Self { Self { clients: HashMap::new(), - server_origins: HashMap::new(), + server_metadata: HashMap::new(), + host_owned_codex_apps_enabled: false, elicitation_requests: ElicitationRequestManager::new( approval_policy.value(), permission_profile.get().clone(), + /*reviewer*/ None, ), startup_cancellation_token: CancellationToken::new(), } @@ -99,7 +103,7 @@ impl McpConnectionManager { pub fn begin_shutdown(&mut self) -> impl std::future::Future + Send + 'static { self.startup_cancellation_token.cancel(); let clients = std::mem::take(&mut self.clients); - self.server_origins.clear(); + self.server_metadata.clear(); async move { for client in clients.into_values() { client.shutdown().await; @@ -113,7 +117,20 @@ impl McpConnectionManager { } pub fn server_origin(&self, server_name: &str) -> Option<&str> { - self.server_origins.get(server_name).map(String::as_str) + self.server_metadata + .get(server_name) + .and_then(|metadata| metadata.origin.as_ref()) + .map(super::server::McpServerOrigin::as_str) + } + + pub fn server_pollutes_memory(&self, server_name: &str) -> bool { + self.server_metadata + .get(server_name) + .is_none_or(|metadata| metadata.pollutes_memory) + } + + pub fn is_host_owned_codex_apps_server(&self, server_name: &str) -> bool { + self.host_owned_codex_apps_enabled && server_name == CODEX_APPS_MCP_SERVER_NAME } pub fn set_approval_policy(&self, approval_policy: &Constrained) { @@ -128,9 +145,17 @@ impl McpConnectionManager { } } + pub fn elicitations_auto_deny(&self) -> bool { + self.elicitation_requests.auto_deny() + } + + pub fn set_elicitations_auto_deny(&self, auto_deny: bool) { + self.elicitation_requests.set_auto_deny(auto_deny); + } + #[allow(clippy::new_ret_no_self, clippy::too_many_arguments)] pub async fn new( - mcp_servers: &HashMap, + mcp_servers: &HashMap, store_mode: OAuthCredentialsStoreMode, auth_entries: HashMap, approval_policy: &Constrained, @@ -140,25 +165,32 @@ impl McpConnectionManager { runtime_environment: McpRuntimeEnvironment, codex_home: PathBuf, codex_apps_tools_cache_key: CodexAppsToolsCacheKey, + host_owned_codex_apps_enabled: bool, + client_elicitation_capability: ElicitationCapability, tool_plugin_provenance: ToolPluginProvenance, auth: Option<&CodexAuth>, + elicitation_reviewer: Option, ) -> (Self, CancellationToken) { let cancel_token = CancellationToken::new(); let mut clients = HashMap::new(); - let mut server_origins = HashMap::new(); + let mut server_metadata = HashMap::new(); let mut join_set = JoinSet::new(); - let elicitation_requests = - ElicitationRequestManager::new(approval_policy.value(), initial_permission_profile); + let elicitation_requests = ElicitationRequestManager::new( + approval_policy.value(), + initial_permission_profile, + elicitation_reviewer, + ); let tool_plugin_provenance = Arc::new(tool_plugin_provenance); let startup_submit_id = submit_id.clone(); let codex_apps_auth_provider = auth .filter(|auth| auth.uses_codex_backend()) .map(codex_model_provider::auth_provider_from_auth); let mcp_servers = mcp_servers.clone(); - for (server_name, cfg) in mcp_servers.into_iter().filter(|(_, cfg)| cfg.enabled) { - if let Some(origin) = transport_origin(&cfg.transport) { - server_origins.insert(server_name.clone(), origin); - } + for (server_name, server) in mcp_servers + .into_iter() + .filter(|(_, server)| server.enabled()) + { + server_metadata.insert(server_name.clone(), McpServerMetadata::from(&server)); let cancel_token = cancel_token.child_token(); let _ = emit_update( startup_submit_id.as_str(), @@ -177,13 +209,16 @@ impl McpConnectionManager { } else { None }; - let uses_env_bearer_token = match &cfg.transport { - McpServerTransportConfig::StreamableHttp { - bearer_token_env_var, - .. - } => bearer_token_env_var.is_some(), - McpServerTransportConfig::Stdio { .. } => false, - }; + let uses_env_bearer_token = + server + .configured_config() + .is_some_and(|config| match &config.transport { + McpServerTransportConfig::StreamableHttp { + bearer_token_env_var, + .. + } => bearer_token_env_var.is_some(), + McpServerTransportConfig::Stdio { .. } => false, + }); let runtime_auth_provider = if server_name == CODEX_APPS_MCP_SERVER_NAME && !uses_env_bearer_token { codex_apps_auth_provider.clone() @@ -192,7 +227,7 @@ impl McpConnectionManager { }; let async_managed_client = AsyncManagedClient::new( server_name.clone(), - cfg, + server, store_mode, cancel_token.clone(), tx_event.clone(), @@ -201,6 +236,7 @@ impl McpConnectionManager { Arc::clone(&tool_plugin_provenance), runtime_environment.clone(), runtime_auth_provider, + client_elicitation_capability.clone(), ); clients.insert(server_name.clone(), async_managed_client.clone()); let tx_event = tx_event.clone(); @@ -239,7 +275,8 @@ impl McpConnectionManager { } let manager = Self { clients, - server_origins, + server_metadata, + host_owned_codex_apps_enabled, elicitation_requests: elicitation_requests.clone(), startup_cancellation_token: cancel_token.clone(), }; @@ -315,26 +352,29 @@ impl McpConnectionManager { failures } - /// Returns a single map that contains all tools. Each key is the - /// fully-qualified name for the tool. + /// Returns all tools with model-visible names normalized. #[instrument(level = "trace", skip_all)] - pub async fn list_all_tools(&self) -> HashMap { + pub async fn list_all_tools(&self) -> Vec { let mut tools = Vec::new(); for managed_client in self.clients.values() { let Some(server_tools) = managed_client.listed_tools().await else { continue; }; - tools.extend(server_tools); + tools.extend( + server_tools + .into_iter() + .map(|tool| self.with_server_metadata(tool)), + ); } - qualify_tools(tools) + normalize_tools_for_model(tools) } /// Force-refresh codex apps tools by bypassing the in-process cache. /// /// On success, the refreshed tools replace the cache contents and the - /// latest filtered tool map is returned directly to the caller. On + /// latest filtered tools are returned directly to the caller. On /// failure, the existing cache remains unchanged. - pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result> { + pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result> { let managed_client = self .clients .get(CODEX_APPS_MCP_SERVER_NAME) @@ -375,9 +415,24 @@ impl McpConnectionManager { .into_iter() .map(|mut tool| { tool.tool = tool_with_model_visible_input_schema(&tool.tool); - tool + self.with_server_metadata(tool) }); - Ok(qualify_tools(tools)) + Ok(normalize_tools_for_model(tools)) + } + + fn with_server_metadata(&self, mut tool: ToolInfo) -> ToolInfo { + let Some(metadata) = self.server_metadata.get(&tool.server_name) else { + tool.supports_parallel_tool_calls = false; + tool.server_origin = None; + return tool; + }; + + tool.supports_parallel_tool_calls = metadata.supports_parallel_tool_calls; + tool.server_origin = metadata + .origin + .as_ref() + .map(|origin| origin.as_str().to_string()); + tool } /// Returns a single map that contains all resources. Each key is the @@ -613,13 +668,6 @@ impl McpConnectionManager { .with_context(|| format!("resources/read failed for `{server}` ({uri})")) } - pub async fn resolve_tool_info(&self, tool_name: &ToolName) -> Option { - let all_tools = self.list_all_tools().await; - all_tools - .into_values() - .find(|tool| tool.canonical_tool_name() == *tool_name) - } - async fn client_by_name(&self, name: &str) -> Result { self.clients .get(name) @@ -650,16 +698,6 @@ async fn emit_update( .await } -fn transport_origin(transport: &McpServerTransportConfig) -> Option { - match transport { - McpServerTransportConfig::StreamableHttp { url, .. } => { - let parsed = Url::parse(url).ok()?; - Some(parsed.origin().ascii_serialization()) - } - McpServerTransportConfig::Stdio { .. } => Some("stdio".to_string()), - } -} - fn mcp_init_error_display( server_name: &str, entry: Option<&McpAuthStatusEntry>, @@ -670,7 +708,7 @@ fn mcp_init_error_display( bearer_token_env_var, http_headers, .. - }) = &entry.map(|entry| &entry.config.transport) + }) = entry.and_then(|entry| entry.config.as_ref().map(|config| &config.transport)) && url == "https://api.githubcopilot.com/mcp/" && bearer_token_env_var.is_none() && http_headers.as_ref().map(HashMap::is_empty).unwrap_or(true) @@ -684,7 +722,11 @@ fn mcp_init_error_display( ) } else if is_mcp_client_startup_timeout_error(err) { let startup_timeout_secs = match entry { - Some(entry) => match entry.config.startup_timeout_sec { + Some(entry) => match entry + .config + .as_ref() + .and_then(|config| config.startup_timeout_sec) + { Some(timeout) => timeout, None => DEFAULT_STARTUP_TIMEOUT, }, diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 01b1161b73..5d04dc2a36 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -10,13 +10,14 @@ use crate::elicitation::elicitation_is_rejected_by_policy; use crate::rmcp_client::AsyncManagedClient; use crate::rmcp_client::ManagedClient; use crate::rmcp_client::StartupOutcomeError; -use crate::rmcp_client::elicitation_capability_for_server; +use crate::server::McpServerOrigin; use crate::tools::ToolFilter; use crate::tools::ToolInfo; use crate::tools::filter_tools; -use crate::tools::qualify_tools; +use crate::tools::normalize_tools_for_model; use crate::tools::tool_with_model_visible_input_schema; use codex_config::Constrained; +use codex_config::McpServerConfig; use codex_protocol::ToolName; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::GranularApprovalConfig; @@ -38,9 +39,11 @@ fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo { let tool_namespace = format!("mcp__{server_name}__"); ToolInfo { server_name: server_name.to_string(), + supports_parallel_tool_calls: false, + server_origin: None, callable_name: tool_name.to_string(), callable_namespace: tool_namespace, - server_instructions: None, + namespace_description: None, tool: Tool { name: tool_name.to_string().into(), title: None, @@ -55,7 +58,6 @@ fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo { connector_id: None, connector_name: None, plugin_display_names: Vec::new(), - connector_description: None, } } @@ -86,6 +88,25 @@ fn create_codex_apps_tools_cache_context( } } +fn model_tool_names(tools: &[ToolInfo]) -> HashSet { + tools + .iter() + .map(ToolInfo::canonical_tool_name) + .collect::>() +} + +fn model_tool_name_len(name: &ToolName) -> usize { + name.namespace.as_deref().map_or(0, str::len) + name.name.len() +} + +fn is_code_mode_compatible_tool_name(name: &ToolName) -> bool { + name.namespace + .as_deref() + .into_iter() + .chain(std::iter::once(name.name.as_str())) + .flat_map(str::chars) + .all(|c| c.is_ascii_alphanumeric() || c == '_') +} #[test] fn declared_openai_file_fields_treat_names_literally() { let meta = serde_json::json!({ @@ -204,8 +225,11 @@ fn elicitation_granular_policy_respects_never_and_config() { #[tokio::test] async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() { - let manager = - ElicitationRequestManager::new(AskForApproval::Never, PermissionProfile::Disabled); + let manager = ElicitationRequestManager::new( + AskForApproval::Never, + PermissionProfile::Disabled, + /*reviewer*/ None, + ); let (tx_event, _rx_event) = async_channel::bounded(1); let sender = manager.make_sender("server".to_string(), tx_event); @@ -234,8 +258,11 @@ async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() { #[tokio::test] async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fields() { - let manager = - ElicitationRequestManager::new(AskForApproval::Never, PermissionProfile::Disabled); + let manager = ElicitationRequestManager::new( + AskForApproval::Never, + PermissionProfile::Disabled, + /*reviewer*/ None, + ); let (tx_event, _rx_event) = async_channel::bounded(1); let sender = manager.make_sender("server".to_string(), tx_event); @@ -267,35 +294,41 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel } #[test] -fn test_qualify_tools_short_non_duplicated_names() { +fn test_normalize_tools_short_non_duplicated_names() { let tools = vec![ create_test_tool("server1", "tool1"), create_test_tool("server1", "tool2"), ]; - let qualified_tools = qualify_tools(tools); + let model_tools = normalize_tools_for_model(tools); - assert_eq!(qualified_tools.len(), 2); - assert!(qualified_tools.contains_key("mcp__server1__tool1")); - assert!(qualified_tools.contains_key("mcp__server1__tool2")); + assert_eq!( + model_tool_names(&model_tools), + HashSet::from([ + ToolName::namespaced("mcp__server1__", "tool1"), + ToolName::namespaced("mcp__server1__", "tool2") + ]) + ); } #[test] -fn test_qualify_tools_duplicated_names_skipped() { +fn test_normalize_tools_duplicated_names_skipped() { let tools = vec![ create_test_tool("server1", "duplicate_tool"), create_test_tool("server1", "duplicate_tool"), ]; - let qualified_tools = qualify_tools(tools); + let model_tools = normalize_tools_for_model(tools); // Only the first tool should remain, the second is skipped - assert_eq!(qualified_tools.len(), 1); - assert!(qualified_tools.contains_key("mcp__server1__duplicate_tool")); + assert_eq!( + model_tool_names(&model_tools), + HashSet::from([ToolName::namespaced("mcp__server1__", "duplicate_tool")]) + ); } #[test] -fn test_qualify_tools_long_names_same_server() { +fn test_normalize_tools_long_names_same_server() { let server_name = "my_server"; let tools = vec![ @@ -309,116 +342,121 @@ fn test_qualify_tools_long_names_same_server() { ), ]; - let qualified_tools = qualify_tools(tools); + let model_tools = normalize_tools_for_model(tools); - assert_eq!(qualified_tools.len(), 2); + assert_eq!(model_tools.len(), 2); - let mut keys: Vec<_> = qualified_tools.keys().cloned().collect(); - keys.sort(); + let names = model_tool_names(&model_tools); - assert!(keys.iter().all(|key| key.len() == 64)); - assert!(keys.iter().all(|key| key.starts_with("mcp__my_server__"))); + assert!(names.iter().all(|name| model_tool_name_len(name) == 64)); assert!( - keys.iter() - .all(|key| key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')), - "qualified names must be code-mode compatible: {keys:?}" + names + .iter() + .all(|name| name.namespace.as_deref() == Some("mcp__my_server__")) + ); + assert!( + names.iter().all(is_code_mode_compatible_tool_name), + "model-visible names must be code-mode compatible: {names:?}" ); } #[test] -fn test_qualify_tools_sanitizes_invalid_characters() { +fn test_normalize_tools_sanitizes_invalid_characters() { let tools = vec![create_test_tool("server.one", "tool.two-three")]; - let qualified_tools = qualify_tools(tools); + let model_tools = normalize_tools_for_model(tools); - assert_eq!(qualified_tools.len(), 1); - let (qualified_name, tool) = qualified_tools.into_iter().next().expect("one tool"); - assert_eq!(qualified_name, "mcp__server_one__tool_two_three"); + assert_eq!(model_tools.len(), 1); + let tool = model_tools.into_iter().next().expect("one tool"); + let model_name = tool.canonical_tool_name(); assert_eq!( - format!("{}{}", tool.callable_namespace, tool.callable_name), - qualified_name + model_name, + ToolName::namespaced("mcp__server_one__", "tool_two_three") ); - - // The key and callable parts are sanitized for model-visible tool calls, but - // the raw MCP name is preserved for the actual MCP call. + assert_eq!( + ToolName::namespaced(tool.callable_namespace.clone(), tool.callable_name.clone()), + model_name + ); + // The callable parts are sanitized for model-visible tool calls, but the raw + // MCP name is preserved for the actual MCP call. assert_eq!(tool.server_name, "server.one"); assert_eq!(tool.callable_namespace, "mcp__server_one__"); assert_eq!(tool.callable_name, "tool_two_three"); assert_eq!(tool.tool.name, "tool.two-three"); assert!( - qualified_name - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_'), - "qualified name must be code-mode compatible: {qualified_name:?}" + is_code_mode_compatible_tool_name(&model_name), + "model-visible name must be code-mode compatible: {model_name:?}" ); } #[test] -fn test_qualify_tools_keeps_hyphenated_mcp_tools_callable() { +fn test_normalize_tools_keeps_hyphenated_mcp_tools_callable() { let tools = vec![create_test_tool("music-studio", "get-strudel-guide")]; - let qualified_tools = qualify_tools(tools); + let model_tools = normalize_tools_for_model(tools); - assert_eq!(qualified_tools.len(), 1); - let (qualified_name, tool) = qualified_tools.into_iter().next().expect("one tool"); - assert_eq!(qualified_name, "mcp__music_studio__get_strudel_guide"); + assert_eq!(model_tools.len(), 1); + let tool = model_tools.into_iter().next().expect("one tool"); + assert_eq!( + tool.canonical_tool_name(), + ToolName::namespaced("mcp__music_studio__", "get_strudel_guide") + ); assert_eq!(tool.callable_namespace, "mcp__music_studio__"); assert_eq!(tool.callable_name, "get_strudel_guide"); assert_eq!(tool.tool.name, "get-strudel-guide"); } #[test] -fn test_qualify_tools_disambiguates_sanitized_namespace_collisions() { +fn test_normalize_tools_disambiguates_sanitized_namespace_collisions() { let tools = vec![ create_test_tool("basic-server", "lookup"), create_test_tool("basic_server", "query"), ]; - let qualified_tools = qualify_tools(tools); + let model_tools = normalize_tools_for_model(tools); - assert_eq!(qualified_tools.len(), 2); - let mut namespaces = qualified_tools - .values() + assert_eq!(model_tools.len(), 2); + let mut namespaces = model_tools + .iter() .map(|tool| tool.callable_namespace.as_str()) .collect::>(); namespaces.sort(); namespaces.dedup(); assert_eq!(namespaces.len(), 2); - let raw_servers = qualified_tools - .values() + let raw_servers = model_tools + .iter() .map(|tool| tool.server_name.as_str()) .collect::>(); assert_eq!(raw_servers, HashSet::from(["basic-server", "basic_server"])); + let model_names = model_tool_names(&model_tools); assert!( - qualified_tools - .keys() - .all(|key| key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')), - "qualified names must be code-mode compatible: {qualified_tools:?}" + model_names.iter().all(is_code_mode_compatible_tool_name), + "model-visible names must be code-mode compatible: {model_names:?}" ); } #[test] -fn test_qualify_tools_disambiguates_sanitized_tool_name_collisions() { +fn test_normalize_tools_disambiguates_sanitized_tool_name_collisions() { let tools = vec![ create_test_tool("server", "tool-name"), create_test_tool("server", "tool_name"), ]; - let qualified_tools = qualify_tools(tools); + let model_tools = normalize_tools_for_model(tools); - assert_eq!(qualified_tools.len(), 2); - let raw_tool_names = qualified_tools - .values() + assert_eq!(model_tools.len(), 2); + let raw_tool_names = model_tools + .iter() .map(|tool| tool.tool.name.to_string()) .collect::>(); assert_eq!( raw_tool_names, HashSet::from(["tool-name".to_string(), "tool_name".to_string()]) ); - let callable_tool_names = qualified_tools - .values() + let callable_tool_names = model_tools + .iter() .map(|tool| tool.callable_name.as_str()) .collect::>(); assert_eq!(callable_tool_names.len(), 2); @@ -667,50 +705,16 @@ async fn list_all_tools_uses_startup_snapshot_while_client_is_pending() { let tools = manager.list_all_tools().await; let tool = tools - .get("mcp__codex_apps__calendar_create_event") + .iter() + .find(|tool| { + tool.canonical_tool_name() + == ToolName::namespaced("mcp__codex_apps__", "calendar_create_event") + }) .expect("tool from startup cache"); assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME); assert_eq!(tool.callable_name, "calendar_create_event"); } -#[tokio::test] -async fn resolve_tool_info_accepts_canonical_namespaced_tool_names() { - let startup_tools = vec![create_test_tool("rmcp", "echo")]; - let pending_client = futures::future::pending::>() - .boxed() - .shared(); - let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); - let permission_profile = Constrained::allow_any(PermissionProfile::default()); - let mut manager = - McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile); - manager.clients.insert( - "rmcp".to_string(), - AsyncManagedClient { - client: pending_client, - startup_snapshot: Some(startup_tools), - startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), - tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), - cancel_token: CancellationToken::new(), - }, - ); - - let tool = manager - .resolve_tool_info(&ToolName::namespaced("mcp__rmcp__", "echo")) - .await - .expect("split MCP tool namespace and name should resolve"); - - let expected = ("rmcp", "mcp__rmcp__", "echo", "echo"); - assert_eq!( - ( - tool.server_name.as_str(), - tool.callable_namespace.as_str(), - tool.callable_name.as_str(), - tool.tool.name.as_ref(), - ), - expected - ); -} - #[tokio::test] async fn list_all_tools_blocks_while_client_is_pending_without_startup_snapshot() { let pending_client = futures::future::pending::>() @@ -793,29 +797,85 @@ async fn list_all_tools_uses_startup_snapshot_when_client_startup_fails() { let tools = manager.list_all_tools().await; let tool = tools - .get("mcp__codex_apps__calendar_create_event") + .iter() + .find(|tool| { + tool.canonical_tool_name() + == ToolName::namespaced("mcp__codex_apps__", "calendar_create_event") + }) .expect("tool from startup cache"); assert_eq!(tool.server_name, CODEX_APPS_MCP_SERVER_NAME); assert_eq!(tool.callable_name, "calendar_create_event"); } +#[tokio::test] +async fn list_all_tools_adds_server_metadata_to_cached_tools() { + let server_name = "docs"; + let startup_tools = vec![create_test_tool(server_name, "search")]; + let pending_client = futures::future::pending::>() + .boxed() + .shared(); + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let mut manager = + McpConnectionManager::new_uninitialized(&approval_policy, &permission_profile); + manager.server_metadata.insert( + server_name.to_string(), + McpServerMetadata { + pollutes_memory: true, + origin: Some(McpServerOrigin::StreamableHttp( + "https://docs.example".to_string(), + )), + supports_parallel_tool_calls: true, + }, + ); + manager.clients.insert( + server_name.to_string(), + AsyncManagedClient { + client: pending_client, + startup_snapshot: Some(startup_tools), + startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), + tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), + cancel_token: CancellationToken::new(), + }, + ); + + let tools = manager.list_all_tools().await; + assert_eq!(tools.len(), 1); + let tool = &tools[0]; + assert_eq!(tool.server_name, server_name); + assert!(tool.supports_parallel_tool_calls); + assert_eq!(tool.server_origin.as_deref(), Some("https://docs.example")); +} + #[test] -fn elicitation_capability_uses_2025_06_18_shape_for_all_servers() { - for server_name in [CODEX_APPS_MCP_SERVER_NAME, "custom_mcp"] { - let capability = elicitation_capability_for_server(server_name); - assert_eq!(capability, Some(ElicitationCapability::default())); - assert_eq!( - serde_json::to_value(capability).expect("serialize elicitation capability"), - serde_json::json!({}) - ); - } +fn elicitation_capability_uses_2025_06_18_shape_for_form_only_support() { + let capability = Some(ElicitationCapability::default()); + assert_eq!( + serde_json::to_value(capability).expect("serialize elicitation capability"), + serde_json::json!({}) + ); +} + +#[test] +fn elicitation_capability_advertises_url_support_when_enabled() { + let capability = Some(ElicitationCapability { + form: Some(rmcp::model::FormElicitationCapability::default()), + url: Some(rmcp::model::UrlElicitationCapability::default()), + }); + assert_eq!( + serde_json::to_value(capability).expect("serialize elicitation capability"), + serde_json::json!({ + "form": {}, + "url": {}, + }) + ); } #[test] fn mcp_init_error_display_prompts_for_github_pat() { let server_name = "github"; let entry = McpAuthStatusEntry { - config: McpServerConfig { + config: Some(McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { url: "https://api.githubcopilot.com/mcp/".to_string(), bearer_token_env_var: None, @@ -835,7 +895,7 @@ fn mcp_init_error_display_prompts_for_github_pat() { scopes: None, oauth_resource: None, tools: HashMap::new(), - }, + }), auth_status: McpAuthStatus::Unsupported, }; let err: StartupOutcomeError = anyhow::anyhow!("OAuth is unsupported").into(); @@ -867,7 +927,7 @@ fn mcp_init_error_display_prompts_for_login_when_auth_required() { fn mcp_init_error_display_reports_generic_errors() { let server_name = "custom"; let entry = McpAuthStatusEntry { - config: McpServerConfig { + config: Some(McpServerConfig { transport: McpServerTransportConfig::StreamableHttp { url: "https://example.com".to_string(), bearer_token_env_var: Some("TOKEN".to_string()), @@ -887,7 +947,7 @@ fn mcp_init_error_display_reports_generic_errors() { scopes: None, oauth_resource: None, tools: HashMap::new(), - }, + }), auth_status: McpAuthStatus::Unsupported, }; let err: StartupOutcomeError = anyhow::anyhow!("boom").into(); @@ -911,31 +971,3 @@ fn mcp_init_error_display_includes_startup_timeout_hint() { display ); } - -#[test] -fn transport_origin_extracts_http_origin() { - let transport = McpServerTransportConfig::StreamableHttp { - url: "https://example.com:8443/path?query=1".to_string(), - bearer_token_env_var: None, - http_headers: None, - env_http_headers: None, - }; - - assert_eq!( - transport_origin(&transport), - Some("https://example.com:8443".to_string()) - ); -} - -#[test] -fn transport_origin_is_stdio_for_stdio_transport() { - let transport = McpServerTransportConfig::Stdio { - command: "server".to_string(), - args: Vec::new(), - env: None, - env_vars: Vec::new(), - cwd: None, - }; - - assert_eq!(transport_origin(&transport), Some("stdio".to_string())); -} diff --git a/codex-rs/codex-mcp/src/elicitation.rs b/codex-rs/codex-mcp/src/elicitation.rs index def12a9d63..a51cd7c623 100644 --- a/codex-rs/codex-mcp/src/elicitation.rs +++ b/codex-rs/codex-mcp/src/elicitation.rs @@ -24,6 +24,7 @@ use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_rmcp_client::ElicitationResponse; use codex_rmcp_client::SendElicitation; +use futures::future::BoxFuture; use futures::future::FutureExt; use rmcp::model::CreateElicitationRequestParams; use rmcp::model::ElicitationAction; @@ -31,22 +32,56 @@ use rmcp::model::RequestId; use tokio::sync::Mutex; use tokio::sync::oneshot; +#[derive(Debug, Clone)] +pub struct ElicitationReviewRequest { + pub server_name: String, + pub request_id: RequestId, + pub elicitation: CreateElicitationRequestParams, +} + +pub trait ElicitationReviewer: Send + Sync { + fn review( + &self, + request: ElicitationReviewRequest, + ) -> BoxFuture<'static, Result>>; +} + +pub type ElicitationReviewerHandle = Arc; + #[derive(Clone)] pub(crate) struct ElicitationRequestManager { requests: Arc>, pub(crate) approval_policy: Arc>, pub(crate) permission_profile: Arc>, + auto_deny: Arc>, + reviewer: Option, } impl ElicitationRequestManager { pub(crate) fn new( approval_policy: AskForApproval, permission_profile: PermissionProfile, + reviewer: Option, ) -> Self { Self { requests: Arc::new(Mutex::new(HashMap::new())), approval_policy: Arc::new(StdMutex::new(approval_policy)), permission_profile: Arc::new(StdMutex::new(permission_profile)), + auto_deny: Arc::new(StdMutex::new(false)), + reviewer, + } + } + + pub(crate) fn auto_deny(&self) -> bool { + self.auto_deny + .lock() + .map(|auto_deny| *auto_deny) + .unwrap_or(false) + } + + pub(crate) fn set_auto_deny(&self, auto_deny: bool) { + if let Ok(mut current) = self.auto_deny.lock() { + *current = auto_deny; } } @@ -73,13 +108,29 @@ impl ElicitationRequestManager { let elicitation_requests = self.requests.clone(); let approval_policy = self.approval_policy.clone(); let permission_profile = self.permission_profile.clone(); + let auto_deny = self.auto_deny.clone(); + let reviewer = self.reviewer.clone(); Box::new(move |id, elicitation| { let elicitation_requests = elicitation_requests.clone(); let tx_event = tx_event.clone(); let server_name = server_name.clone(); let approval_policy = approval_policy.clone(); let permission_profile = permission_profile.clone(); + let auto_deny = auto_deny.clone(); + let reviewer = reviewer.clone(); async move { + let auto_deny = auto_deny + .lock() + .map(|auto_deny| *auto_deny) + .unwrap_or(false); + if auto_deny { + return Ok(ElicitationResponse { + action: ElicitationAction::Decline, + content: None, + meta: None, + }); + } + let approval_policy = approval_policy .lock() .map(|policy| *policy) @@ -109,6 +160,17 @@ impl ElicitationRequestManager { }); } + if let Some(reviewer) = reviewer.as_ref() { + let request = ElicitationReviewRequest { + server_name: server_name.clone(), + request_id: id.clone(), + elicitation: elicitation.clone(), + }; + if let Some(response) = reviewer.review(request).await? { + return Ok(response); + } + } + let request = match elicitation { CreateElicitationRequestParams::FormElicitationParams { meta, diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 9d4ee60e89..9d1fe223d6 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -1,4 +1,7 @@ pub use connection_manager::McpConnectionManager; +pub use elicitation::ElicitationReviewRequest; +pub use elicitation::ElicitationReviewer; +pub use elicitation::ElicitationReviewerHandle; pub use rmcp_client::MCP_SANDBOX_STATE_META_CAPABILITY; pub use runtime::McpRuntimeEnvironment; pub use runtime::SandboxState; @@ -7,19 +10,29 @@ pub use tools::ToolInfo; pub use mcp::CODEX_APPS_MCP_SERVER_NAME; pub use mcp::McpConfig; pub use mcp::ToolPluginProvenance; +pub use server::EffectiveMcpServer; +pub use auth_elicitation::CodexAppsAuthElicitation; +pub use auth_elicitation::CodexAppsAuthElicitationPlan; +pub use auth_elicitation::CodexAppsConnectorAuthFailure; +pub use auth_elicitation::MCP_TOOL_CODEX_APPS_META_KEY; +pub use auth_elicitation::auth_elicitation_completed_result; +pub use auth_elicitation::auth_elicitation_id; +pub use auth_elicitation::build_auth_elicitation; +pub use auth_elicitation::build_auth_elicitation_plan; +pub use auth_elicitation::connector_auth_failure_from_tool_result; pub use codex_apps::CodexAppsToolsCacheKey; pub use codex_apps::codex_apps_tools_cache_key; - pub use mcp::configured_mcp_servers; pub use mcp::effective_mcp_servers; +pub use mcp::effective_mcp_servers_from_configured; +pub use mcp::host_owned_codex_apps_enabled; pub use mcp::tool_plugin_provenance; pub use mcp::with_codex_apps_mcp; pub use mcp::McpServerStatusSnapshot; pub use mcp::McpSnapshotDetail; pub use mcp::collect_mcp_server_status_snapshot_with_detail; -pub use mcp::collect_mcp_snapshot_from_manager; pub use mcp::read_mcp_resource; pub use mcp::McpAuthStatusEntry; @@ -33,16 +46,17 @@ pub use mcp::oauth_login_support; pub use mcp::resolve_oauth_scopes; pub use mcp::should_retry_without_scopes; -pub use codex_apps::filter_non_codex_apps_mcp_tools_only; pub use mcp::McpPermissionPromptAutoApproveContext; pub use mcp::mcp_permission_prompt_is_auto_approved; pub use mcp::qualified_mcp_tool_name_prefix; pub use tools::declared_openai_file_input_param_names; +pub(crate) mod auth_elicitation; pub(crate) mod codex_apps; pub(crate) mod connection_manager; pub(crate) mod elicitation; pub(crate) mod mcp; pub(crate) mod rmcp_client; pub(crate) mod runtime; +pub(crate) mod server; pub(crate) mod tools; diff --git a/codex-rs/codex-mcp/src/mcp/auth.rs b/codex-rs/codex-mcp/src/mcp/auth.rs index 6a97b52789..12f832f9e9 100644 --- a/codex-rs/codex-mcp/src/mcp/auth.rs +++ b/codex-rs/codex-mcp/src/mcp/auth.rs @@ -12,6 +12,8 @@ use codex_rmcp_client::discover_streamable_http_oauth; use futures::future::join_all; use tracing::warn; +use crate::server::EffectiveMcpServer; + use super::CODEX_APPS_MCP_SERVER_NAME; #[derive(Debug, Clone)] @@ -45,7 +47,7 @@ pub struct ResolvedMcpOAuthScopes { #[derive(Debug, Clone)] pub struct McpAuthStatusEntry { - pub config: McpServerConfig, + pub config: Option, pub auth_status: McpAuthStatus, } @@ -131,29 +133,37 @@ pub async fn compute_auth_statuses<'a, I>( auth: Option<&CodexAuth>, ) -> HashMap where - I: IntoIterator, + I: IntoIterator, { - let futures = servers.into_iter().map(|(name, config)| { + let futures = servers.into_iter().map(|(name, server)| { let name = name.clone(); - let config = config.clone(); + let config = server.configured_config().cloned(); let has_runtime_auth = name == CODEX_APPS_MCP_SERVER_NAME && auth.is_some_and(CodexAuth::uses_codex_backend) - && matches!( - &config.transport, - McpServerTransportConfig::StreamableHttp { - bearer_token_env_var: None, - .. - } - ); - async move { - let auth_status = - match compute_auth_status(&name, &config, store_mode, has_runtime_auth).await { - Ok(status) => status, - Err(error) => { - warn!("failed to determine auth status for MCP server `{name}`: {error:?}"); - McpAuthStatus::Unsupported + && config.as_ref().is_some_and(|config| { + matches!( + &config.transport, + McpServerTransportConfig::StreamableHttp { + bearer_token_env_var: None, + .. } - }; + ) + }); + async move { + let auth_status = match config.as_ref() { + Some(config) => { + match compute_auth_status(&name, config, store_mode, has_runtime_auth).await { + Ok(status) => status, + Err(error) => { + warn!( + "failed to determine auth status for MCP server `{name}`: {error:?}" + ); + McpAuthStatus::Unsupported + } + } + } + None => McpAuthStatus::Unsupported, + }; let entry = McpAuthStatusEntry { config, auth_status, diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 3cfd4d01e1..b8fb9a080c 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -31,7 +31,7 @@ use codex_protocol::mcp::Tool; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::McpAuthStatus; -use codex_protocol::protocol::McpListToolsResponseEvent; +use rmcp::model::ElicitationCapability; use rmcp::model::ReadResourceRequestParams; use rmcp::model::ReadResourceResult; use serde_json::Value; @@ -39,6 +39,7 @@ use serde_json::Value; use crate::codex_apps::codex_apps_tools_cache_key; use crate::connection_manager::McpConnectionManager; use crate::runtime::McpRuntimeEnvironment; +use crate::server::EffectiveMcpServer; pub const CODEX_APPS_MCP_SERVER_NAME: &str = "codex_apps"; const MCP_TOOL_NAME_PREFIX: &str = "mcp"; @@ -71,12 +72,7 @@ pub fn mcp_permission_prompt_is_auto_approved( permission_profile: &PermissionProfile, context: McpPermissionPromptAutoApproveContext, ) -> bool { - if matches!( - approval_policy, - AskForApproval::OnRequest | AskForApproval::Granular(_) - ) && context.approvals_reviewer == Some(ApprovalsReviewer::AutoReview) - && context.tool_approval_mode == Some(AppToolApproval::Approve) - { + if context.tool_approval_mode == Some(AppToolApproval::Approve) { return true; } @@ -105,13 +101,13 @@ pub struct McpPermissionPromptAutoApproveContext { /// 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 snapshot collection helpers so config objects do -/// not go stale when auth changes. +/// [`effective_mcp_servers`] and snapshot collection helpers 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, - /// Optional path override for the built-in apps MCP server. + /// Optional path override for the host-owned apps MCP server. pub apps_mcp_path_override: Option, /// Codex home directory used for MCP OAuth state and app-tool cache files. pub codex_home: PathBuf, @@ -131,10 +127,14 @@ pub struct McpConfig { 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 + /// ChatGPT auth is checked separately at runtime before the host-owned apps /// MCP server is added. pub apps_enabled: bool, - /// User-configured and plugin-provided MCP servers keyed by server name. + /// Client-side elicitation capabilities advertised during MCP initialization. + pub client_elicitation_capability: ElicitationCapability, + /// Config-backed MCP servers keyed by server name. + /// + /// Runtime-only additions are merged later by [`effective_mcp_servers`]. pub configured_mcp_servers: HashMap, /// Plugin metadata used to attribute MCP tools/connectors to plugin display names. pub plugin_capability_summaries: Vec, @@ -199,14 +199,14 @@ impl ToolPluginProvenance { } pub fn with_codex_apps_mcp( - mut servers: HashMap, + mut servers: HashMap, auth: Option<&CodexAuth>, config: &McpConfig, -) -> HashMap { - if config.apps_enabled && auth.is_some_and(CodexAuth::uses_codex_backend) { +) -> HashMap { + if host_owned_codex_apps_enabled(config, auth) { servers.insert( CODEX_APPS_MCP_SERVER_NAME.to_string(), - codex_apps_mcp_server_config(config), + EffectiveMcpServer::configured(codex_apps_mcp_server_config(config)), ); } else { servers.remove(CODEX_APPS_MCP_SERVER_NAME); @@ -214,6 +214,10 @@ pub fn with_codex_apps_mcp( servers } +pub fn host_owned_codex_apps_enabled(config: &McpConfig, auth: Option<&CodexAuth>) -> bool { + config.apps_enabled && auth.is_some_and(CodexAuth::uses_codex_backend) +} + pub fn configured_mcp_servers(config: &McpConfig) -> HashMap { config.configured_mcp_servers.clone() } @@ -221,8 +225,19 @@ pub fn configured_mcp_servers(config: &McpConfig) -> HashMap, -) -> HashMap { - let servers = configured_mcp_servers(config); +) -> HashMap { + effective_mcp_servers_from_configured(configured_mcp_servers(config), config, auth) +} + +pub fn effective_mcp_servers_from_configured( + configured_servers: HashMap, + config: &McpConfig, + auth: Option<&CodexAuth>, +) -> HashMap { + let servers = configured_servers + .into_iter() + .map(|(name, server)| (name, EffectiveMcpServer::configured(server))) + .collect::>(); with_codex_apps_mcp(servers, auth, config) } @@ -238,6 +253,7 @@ pub async fn read_mcp_resource( uri: &str, ) -> anyhow::Result { let mut mcp_servers = effective_mcp_servers(config, auth); + let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth); mcp_servers.retain(|name, _| name == server); let auth_statuses = compute_auth_statuses( mcp_servers.iter(), @@ -258,8 +274,11 @@ pub async fn read_mcp_resource( runtime_environment, config.codex_home.clone(), codex_apps_tools_cache_key(auth), + host_owned_codex_apps_enabled, + config.client_elicitation_capability.clone(), tool_plugin_provenance(config), auth, + /*elicitation_reviewer*/ None, ) .await; @@ -292,6 +311,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( detail: McpSnapshotDetail, ) -> McpServerStatusSnapshot { let mcp_servers = effective_mcp_servers(config, auth); + let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth); let tool_plugin_provenance = tool_plugin_provenance(config); if mcp_servers.is_empty() { return McpServerStatusSnapshot { @@ -323,8 +343,11 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( runtime_environment, config.codex_home.clone(), codex_apps_tools_cache_key(auth), + host_owned_codex_apps_enabled, + config.client_elicitation_capability.clone(), tool_plugin_provenance, auth, + /*elicitation_reviewer*/ None, ) .await; @@ -340,18 +363,6 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( snapshot } -pub async fn collect_mcp_snapshot_from_manager( - mcp_connection_manager: &McpConnectionManager, - auth_status_entries: HashMap, -) -> McpListToolsResponseEvent { - collect_mcp_snapshot_from_manager_with_detail( - mcp_connection_manager, - auth_status_entries, - McpSnapshotDetail::Full, - ) - .await -} - pub(crate) fn codex_apps_mcp_url(config: &McpConfig) -> String { codex_apps_mcp_url_for_base_url( &config.chatgpt_base_url, @@ -568,7 +579,7 @@ async fn collect_mcp_server_status_snapshot_from_manager( ); let mut tools_by_server = HashMap::>::new(); - for (_qualified_name, tool_info) in tools { + for tool_info in tools { let raw_tool_name = tool_info.tool.name.to_string(); let Some(tool) = protocol_tool_from_rmcp_tool(&raw_tool_name, &tool_info.tool) else { continue; @@ -588,44 +599,6 @@ async fn collect_mcp_server_status_snapshot_from_manager( } } -async fn collect_mcp_snapshot_from_manager_with_detail( - mcp_connection_manager: &McpConnectionManager, - auth_status_entries: HashMap, - detail: McpSnapshotDetail, -) -> McpListToolsResponseEvent { - let (tools, resources, resource_templates) = tokio::join!( - mcp_connection_manager.list_all_tools(), - async { - if detail.include_resources() { - mcp_connection_manager.list_all_resources().await - } else { - HashMap::new() - } - }, - async { - if detail.include_resources() { - mcp_connection_manager.list_all_resource_templates().await - } else { - HashMap::new() - } - }, - ); - - let tools = tools - .into_iter() - .filter_map(|(name, tool)| { - protocol_tool_from_rmcp_tool(&name, &tool.tool).map(|tool| (name, tool)) - }) - .collect::>(); - - McpListToolsResponseEvent { - tools, - resources: convert_mcp_resources(resources), - resource_templates: convert_mcp_resource_templates(resource_templates), - auth_statuses: auth_statuses_from_entries(&auth_status_entries), - } -} - #[cfg(test)] #[path = "mod_tests.rs"] mod tests; diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index fa5cbf1f7a..0d0afc6c48 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -27,6 +27,7 @@ fn test_mcp_config(codex_home: PathBuf) -> McpConfig { codex_linux_sandbox_exe: None, use_legacy_landlock: false, apps_enabled: false, + client_elicitation_capability: ElicitationCapability::default(), configured_mcp_servers: HashMap::new(), plugin_capability_summaries: Vec::new(), } @@ -74,16 +75,11 @@ fn mcp_prompt_auto_approval_honors_unrestricted_managed_profiles() { } #[test] -fn mcp_prompt_auto_approval_honors_auto_review_approved_tools() { - assert!(mcp_permission_prompt_is_auto_approved( +fn mcp_prompt_auto_approval_honors_approved_tools_in_all_permission_modes() { + for approval_policy in [ + AskForApproval::UnlessTrusted, + AskForApproval::OnFailure, AskForApproval::OnRequest, - &PermissionProfile::read_only(), - McpPermissionPromptAutoApproveContext { - approvals_reviewer: Some(ApprovalsReviewer::AutoReview), - tool_approval_mode: Some(AppToolApproval::Approve), - }, - )); - assert!(mcp_permission_prompt_is_auto_approved( AskForApproval::Granular(GranularApprovalConfig { sandbox_approval: true, rules: true, @@ -91,34 +87,36 @@ fn mcp_prompt_auto_approval_honors_auto_review_approved_tools() { request_permissions: true, mcp_elicitations: true, }), + AskForApproval::Never, + ] { + assert!(mcp_permission_prompt_is_auto_approved( + approval_policy, + &PermissionProfile::read_only(), + McpPermissionPromptAutoApproveContext { + approvals_reviewer: Some(ApprovalsReviewer::User), + tool_approval_mode: Some(AppToolApproval::Approve), + }, + )); + } + + assert!(!mcp_permission_prompt_is_auto_approved( + AskForApproval::OnRequest, &PermissionProfile::read_only(), McpPermissionPromptAutoApproveContext { approvals_reviewer: Some(ApprovalsReviewer::AutoReview), - tool_approval_mode: Some(AppToolApproval::Approve), + tool_approval_mode: Some(AppToolApproval::Auto), }, )); +} + +#[test] +fn mcp_prompt_auto_approval_rejects_auto_mode_in_default_permission_mode() { assert!(!mcp_permission_prompt_is_auto_approved( AskForApproval::OnRequest, &PermissionProfile::read_only(), McpPermissionPromptAutoApproveContext { approvals_reviewer: Some(ApprovalsReviewer::User), - tool_approval_mode: Some(AppToolApproval::Approve), - }, - )); - assert!(!mcp_permission_prompt_is_auto_approved( - AskForApproval::OnFailure, - &PermissionProfile::read_only(), - McpPermissionPromptAutoApproveContext { - approvals_reviewer: Some(ApprovalsReviewer::AutoReview), - tool_approval_mode: Some(AppToolApproval::Approve), - }, - )); - assert!(!mcp_permission_prompt_is_auto_approved( - AskForApproval::UnlessTrusted, - &PermissionProfile::read_only(), - McpPermissionPromptAutoApproveContext { - approvals_reviewer: Some(ApprovalsReviewer::AutoReview), - tool_approval_mode: Some(AppToolApproval::Approve), + tool_approval_mode: Some(AppToolApproval::Auto), }, )); } @@ -220,7 +218,10 @@ fn codex_apps_server_config_uses_legacy_codex_apps_path() { let server = servers .get(CODEX_APPS_MCP_SERVER_NAME) .expect("codex apps should be present when apps is enabled"); - let url = match &server.transport { + let config = server + .configured_config() + .expect("codex apps should use configured transport"); + let url = match &config.transport { McpServerTransportConfig::StreamableHttp { url, .. } => url, _ => panic!("expected streamable http transport for codex apps"), }; @@ -239,7 +240,10 @@ fn codex_apps_server_config_uses_configured_apps_mcp_path_override() { let server = servers .get(CODEX_APPS_MCP_SERVER_NAME) .expect("codex apps should be present when apps is enabled"); - let url = match &server.transport { + let config = server + .configured_config() + .expect("codex apps should use configured transport"); + let url = match &config.transport { McpServerTransportConfig::StreamableHttp { url, .. } => url, _ => panic!("expected streamable http transport for codex apps"), }; @@ -313,6 +317,16 @@ async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() { .get(CODEX_APPS_MCP_SERVER_NAME) .expect("codex apps server should exist"); + let sample = sample + .configured_config() + .expect("configured server should retain transport"); + let docs = docs + .configured_config() + .expect("configured server should retain transport"); + let codex_apps = codex_apps + .configured_config() + .expect("codex apps should use configured transport"); + match &sample.transport { McpServerTransportConfig::StreamableHttp { url, .. } => { assert_eq!(url, "https://user.example/mcp"); diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 38ef96919f..fcc9d1c86e 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -30,6 +30,8 @@ use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::ToolPluginProvenance; use crate::runtime::McpRuntimeEnvironment; use crate::runtime::emit_duration; +use crate::server::EffectiveMcpServer; +use crate::server::McpServerLaunch; use crate::tools::ToolFilter; use crate::tools::ToolInfo; use crate::tools::filter_tools; @@ -133,7 +135,7 @@ impl AsyncManagedClient { #[allow(clippy::too_many_arguments)] pub(crate) fn new( server_name: String, - config: McpServerConfig, + server: EffectiveMcpServer, store_mode: OAuthCredentialsStoreMode, cancel_token: CancellationToken, tx_event: Sender, @@ -142,8 +144,12 @@ impl AsyncManagedClient { tool_plugin_provenance: Arc, runtime_environment: McpRuntimeEnvironment, runtime_auth_provider: Option, + client_elicitation_capability: ElicitationCapability, ) -> Self { - let tool_filter = ToolFilter::from_config(&config); + let tool_filter = server + .configured_config() + .map(ToolFilter::from_config) + .unwrap_or_default(); let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot( &server_name, codex_apps_tools_cache_context.as_ref(), @@ -162,7 +168,7 @@ impl AsyncManagedClient { let client = Arc::new( make_rmcp_client( &server_name, - config.clone(), + server.clone(), store_mode, runtime_environment, runtime_auth_provider, @@ -173,14 +179,19 @@ impl AsyncManagedClient { server_name, client, StartServerTaskParams { - startup_timeout: config - .startup_timeout_sec + startup_timeout: server + .configured_config() + .and_then(|config| config.startup_timeout_sec) .or(Some(DEFAULT_STARTUP_TIMEOUT)), - tool_timeout: config.tool_timeout_sec.unwrap_or(DEFAULT_TOOL_TIMEOUT), + tool_timeout: server + .configured_config() + .and_then(|config| config.tool_timeout_sec) + .unwrap_or(DEFAULT_TOOL_TIMEOUT), tool_filter: startup_tool_filter, tx_event, elicitation_requests, codex_apps_tools_cache_context, + client_elicitation_capability, }, ) .await @@ -317,14 +328,6 @@ impl From for StartupOutcomeError { } } -pub(crate) fn elicitation_capability_for_server( - _server_name: &str, -) -> Option { - // https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#capabilities - // indicates this should be an empty object. - Some(ElicitationCapability::default()) -} - pub(crate) async fn list_tools_for_client_uncached( server_name: &str, client: &Arc, @@ -362,16 +365,25 @@ pub(crate) async fn list_tools_for_client_uncached( tool_def.title = Some(normalized_title); } } + let has_connector_metadata = connector_id.is_some() + || connector_name.is_some() + || connector_description.is_some(); + let namespace_description = if has_connector_metadata { + connector_description + } else { + server_instructions.map(str::to_string) + }; ToolInfo { server_name: server_name.to_owned(), + supports_parallel_tool_calls: false, + server_origin: None, callable_name, callable_namespace, - server_instructions: server_instructions.map(str::to_string), + namespace_description, tool: tool_def, connector_id, connector_name, plugin_display_names: Vec::new(), - connector_description, } }) .collect(); @@ -456,8 +468,8 @@ async fn start_server_task( tx_event, elicitation_requests, codex_apps_tools_cache_context, + client_elicitation_capability, } = params; - let elicitation = elicitation_capability_for_server(&server_name); let params = InitializeRequestParams { meta: None, capabilities: ClientCapabilities { @@ -465,7 +477,7 @@ async fn start_server_task( extensions: None, roots: None, sampling: None, - elicitation, + elicitation: Some(client_elicitation_capability), tasks: None, }, client_info: Implementation { @@ -541,15 +553,19 @@ struct StartServerTaskParams { tx_event: Sender, elicitation_requests: ElicitationRequestManager, codex_apps_tools_cache_context: Option, + client_elicitation_capability: ElicitationCapability, } async fn make_rmcp_client( server_name: &str, - config: McpServerConfig, + server: EffectiveMcpServer, store_mode: OAuthCredentialsStoreMode, runtime_environment: McpRuntimeEnvironment, runtime_auth_provider: Option, ) -> Result { + let config = match server.launch() { + McpServerLaunch::Configured(config) => config.as_ref().clone(), + }; let McpServerConfig { transport, experimental_environment, diff --git a/codex-rs/codex-mcp/src/server.rs b/codex-rs/codex-mcp/src/server.rs new file mode 100644 index 0000000000..d8fb5a11f2 --- /dev/null +++ b/codex-rs/codex-mcp/src/server.rs @@ -0,0 +1,90 @@ +use codex_config::McpServerConfig; +use codex_config::McpServerTransportConfig; + +/// The runtime launch strategy for an effective MCP server. +#[derive(Debug, Clone)] +pub(crate) enum McpServerLaunch { + Configured(Box), +} + +/// MCP server after runtime additions have been applied. +#[derive(Debug, Clone)] +pub struct EffectiveMcpServer { + launch: McpServerLaunch, +} + +impl EffectiveMcpServer { + pub fn configured(config: McpServerConfig) -> Self { + Self { + launch: McpServerLaunch::Configured(Box::new(config)), + } + } + + pub(crate) fn launch(&self) -> &McpServerLaunch { + &self.launch + } + + pub fn configured_config(&self) -> Option<&McpServerConfig> { + match &self.launch { + McpServerLaunch::Configured(config) => Some(config.as_ref()), + } + } + + pub fn enabled(&self) -> bool { + match &self.launch { + McpServerLaunch::Configured(config) => config.enabled, + } + } + + pub fn required(&self) -> bool { + match &self.launch { + McpServerLaunch::Configured(config) => config.required, + } + } +} + +/// Transport origin retained for metrics and diagnostics after server launch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum McpServerOrigin { + Stdio, + StreamableHttp(String), +} + +impl McpServerOrigin { + pub fn as_str(&self) -> &str { + match self { + Self::Stdio => "stdio", + Self::StreamableHttp(origin) => origin, + } + } + + fn from_transport(transport: &McpServerTransportConfig) -> Option { + match transport { + McpServerTransportConfig::StreamableHttp { url, .. } => { + let parsed = url::Url::parse(url).ok()?; + Some(Self::StreamableHttp(parsed.origin().ascii_serialization())) + } + McpServerTransportConfig::Stdio { .. } => Some(Self::Stdio), + } + } +} + +/// Semantic metadata that must survive after the server is launched. +#[derive(Debug, Clone)] +pub(crate) struct McpServerMetadata { + pub pollutes_memory: bool, + pub origin: Option, + pub supports_parallel_tool_calls: bool, +} + +impl From<&EffectiveMcpServer> for McpServerMetadata { + fn from(server: &EffectiveMcpServer) -> Self { + match server.launch() { + McpServerLaunch::Configured(config) => Self { + pollutes_memory: true, + origin: McpServerOrigin::from_transport(&config.transport), + supports_parallel_tool_calls: config.supports_parallel_tool_calls, + }, + } + } +} diff --git a/codex-rs/codex-mcp/src/tools.rs b/codex-rs/codex-mcp/src/tools.rs index 9b677e8a07..5b2fba2ef8 100644 --- a/codex-rs/codex-mcp/src/tools.rs +++ b/codex-rs/codex-mcp/src/tools.rs @@ -1,4 +1,4 @@ -//! MCP tool metadata, filtering, schema shaping, and name qualification. +//! MCP tool metadata, filtering, schema shaping, and name normalization. //! //! Raw MCP tool identities must be preserved for protocol calls, while //! model-visible tool names must be sanitized, deduplicated, and kept within API @@ -29,22 +29,28 @@ pub(crate) const MCP_TOOLS_CACHE_WRITE_DURATION_METRIC: &str = pub struct ToolInfo { /// Raw MCP server name used for routing the tool call. pub server_name: String, + /// Whether calls routed to this server may run in parallel. + #[serde(default)] + pub supports_parallel_tool_calls: bool, + /// MCP server origin used for telemetry and diagnostics, when known. + #[serde(default)] + pub server_origin: Option, /// Model-visible tool name used in Responses API tool declarations. #[serde(rename = "tool_name", alias = "callable_name")] pub callable_name: String, /// Model-visible namespace used for deferred tool loading. #[serde(rename = "tool_namespace", alias = "callable_namespace")] pub callable_namespace: String, - /// Instructions from the MCP server initialize result. - #[serde(default)] - pub server_instructions: Option, + /// Model-visible namespace description. + // Keep the old serialized field name readable for cached ToolInfo values. + #[serde(default, alias = "connector_description")] + pub namespace_description: Option, /// Raw MCP tool definition; `tool.name` is sent back to the MCP server. pub tool: Tool, pub connector_id: Option, pub connector_name: Option, #[serde(default)] pub plugin_display_names: Vec, - pub connector_description: Option, } impl ToolInfo { @@ -130,12 +136,12 @@ pub(crate) fn filter_tools(tools: Vec, filter: &ToolFilter) -> Vec(tools: I) -> HashMap +/// every model-visible name is unique and <= 64 bytes. +pub(crate) fn normalize_tools_for_model(tools: I) -> Vec where I: IntoIterator, { @@ -213,9 +219,9 @@ where candidates.sort_by(|left, right| left.raw_tool_identity.cmp(&right.raw_tool_identity)); let mut used_names = HashSet::new(); - let mut qualified_tools = HashMap::new(); + let mut model_tools = Vec::new(); for mut candidate in candidates { - let (callable_namespace, callable_name, qualified_name) = unique_callable_parts( + let (callable_namespace, callable_name) = unique_callable_parts( &candidate.callable_namespace, &candidate.callable_name, &candidate.raw_tool_identity, @@ -223,9 +229,9 @@ where ); candidate.tool.callable_namespace = callable_namespace; candidate.tool.callable_name = callable_name; - qualified_tools.insert(qualified_name, candidate.tool); + model_tools.push(candidate.tool); } - qualified_tools + model_tools } #[derive(Debug)] @@ -345,10 +351,10 @@ fn unique_callable_parts( tool_name: &str, raw_identity: &str, used_names: &mut HashSet, -) -> (String, String, String) { - let qualified_name = format!("{namespace}{tool_name}"); - if qualified_name.len() <= MAX_TOOL_NAME_LENGTH && used_names.insert(qualified_name.clone()) { - return (namespace.to_string(), tool_name.to_string(), qualified_name); +) -> (String, String) { + let model_name = format!("{namespace}{tool_name}"); + if model_name.len() <= MAX_TOOL_NAME_LENGTH && used_names.insert(model_name) { + return (namespace.to_string(), tool_name.to_string()); } let mut attempt = 0_u32; @@ -360,9 +366,9 @@ fn unique_callable_parts( }; let (namespace, tool_name) = fit_callable_parts_with_hash(namespace, tool_name, &hash_input); - let qualified_name = format!("{namespace}{tool_name}"); - if used_names.insert(qualified_name.clone()) { - return (namespace, tool_name, qualified_name); + let model_name = format!("{namespace}{tool_name}"); + if used_names.insert(model_name) { + return (namespace, tool_name); } attempt = attempt.saturating_add(1); } diff --git a/codex-rs/collaboration-mode-templates/Cargo.toml b/codex-rs/collaboration-mode-templates/Cargo.toml index 18c4e99822..2c17b1fd2a 100644 --- a/codex-rs/collaboration-mode-templates/Cargo.toml +++ b/codex-rs/collaboration-mode-templates/Cargo.toml @@ -8,6 +8,7 @@ version.workspace = true doctest = false name = "codex_collaboration_mode_templates" path = "src/lib.rs" +test = false [lints] workspace = true diff --git a/codex-rs/config/Cargo.toml b/codex-rs/config/Cargo.toml index 8cef4070c9..9583a57c62 100644 --- a/codex-rs/config/Cargo.toml +++ b/codex-rs/config/Cargo.toml @@ -66,3 +66,6 @@ tokio = { workspace = true, features = ["full"] } tokio-stream = { workspace = true, features = ["net"] } tonic = { workspace = true, features = ["router", "transport"] } tonic-prost-build = { version = "=0.14.3", default-features = false, features = ["transport"] } + +[lib] +doctest = false diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 59d1cde9ea..9faa846137 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -18,6 +18,7 @@ use super::requirements_exec_policy::RequirementsExecPolicyToml; use crate::Constrained; use crate::ConstraintError; use crate::ManagedHooksRequirementsToml; +use crate::mcp_types::AppToolApproval; #[derive(Debug, Clone, PartialEq, Eq)] pub enum RequirementSource { @@ -86,6 +87,7 @@ pub struct ConfigRequirements { pub approvals_reviewer: ConstrainedWithSource, pub permission_profile: ConstrainedWithSource, pub web_search_mode: ConstrainedWithSource, + pub allow_managed_hooks_only: Option>, pub feature_requirements: Option>, pub managed_hooks: Option>, pub mcp_servers: Option>>, @@ -119,6 +121,7 @@ impl Default for ConfigRequirements { Constrained::allow_any(WebSearchMode::Cached), /*source*/ None, ), + allow_managed_hooks_only: None, feature_requirements: None, managed_hooks: None, mcp_servers: None, @@ -597,9 +600,43 @@ impl FeatureRequirementsToml { } } +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct AppToolRequirementToml { + pub approval_mode: Option, +} + +impl AppToolRequirementToml { + pub fn is_empty(&self) -> bool { + self.approval_mode.is_none() + } +} + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct AppToolsRequirementsToml { + #[serde(default, flatten)] + pub tools: BTreeMap, +} + +impl AppToolsRequirementsToml { + pub fn is_empty(&self) -> bool { + self.tools.values().all(AppToolRequirementToml::is_empty) + } +} + #[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] pub struct AppRequirementToml { pub enabled: Option, + pub tools: Option, +} + +impl AppRequirementToml { + pub fn is_empty(&self) -> bool { + self.enabled.is_none() + && self + .tools + .as_ref() + .is_none_or(AppToolsRequirementsToml::is_empty) + } } #[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] @@ -610,14 +647,14 @@ pub struct AppsRequirementsToml { impl AppsRequirementsToml { pub fn is_empty(&self) -> bool { - self.apps.values().all(|app| app.enabled.is_none()) + self.apps.values().all(AppRequirementToml::is_empty) } } -/// Merge `enabled` configs from a lower-precedence source into an existing higher-precedence set. -/// This lets managed sources (for example Cloud/MDM) enforce setting disablement across layers. -/// Implemented with AppsRequirementsToml for now, could be abstracted if we have more enablement-style configs in the future. -pub(crate) fn merge_enablement_settings_descending( +/// Merge app requirements from a lower-precedence source into an existing higher-precedence set. +/// This lets managed sources (for example Cloud/MDM) enforce setting disablement across layers, +/// while exact tool approval settings keep the higher-precedence value when present. +pub(crate) fn merge_app_requirements_descending( base: &mut AppsRequirementsToml, incoming: AppsRequirementsToml, ) { @@ -631,6 +668,17 @@ pub(crate) fn merge_enablement_settings_descending( } else { higher_precedence.or(lower_precedence) }; + + let Some(incoming_tools) = incoming_requirement.tools else { + continue; + }; + let base_tools = base_requirement.tools.get_or_insert_with(Default::default); + for (tool_name, incoming_tool) in incoming_tools.tools { + let base_tool = base_tools.tools.entry(tool_name).or_default(); + if base_tool.approval_mode.is_none() { + base_tool.approval_mode = incoming_tool.approval_mode; + } + } } } @@ -642,6 +690,7 @@ pub struct ConfigRequirementsToml { pub allowed_sandbox_modes: Option>, pub remote_sandbox_config: Option>, pub allowed_web_search_modes: Option>, + pub allow_managed_hooks_only: Option, #[serde(rename = "features", alias = "feature_requirements")] pub feature_requirements: Option, pub hooks: Option, @@ -690,6 +739,7 @@ pub struct ConfigRequirementsWithSources { pub allowed_approvals_reviewers: Option>>, pub allowed_sandbox_modes: Option>>, pub allowed_web_search_modes: Option>>, + pub allow_managed_hooks_only: Option>, pub feature_requirements: Option>, pub hooks: Option>, pub mcp_servers: Option>>, @@ -726,6 +776,7 @@ impl ConfigRequirementsWithSources { allowed_sandbox_modes: _, remote_sandbox_config: _, allowed_web_search_modes: _, + allow_managed_hooks_only: _, feature_requirements: _, hooks: _, mcp_servers: _, @@ -755,6 +806,7 @@ impl ConfigRequirementsWithSources { allowed_approvals_reviewers, allowed_sandbox_modes, allowed_web_search_modes, + allow_managed_hooks_only, feature_requirements, hooks, mcp_servers, @@ -769,7 +821,7 @@ impl ConfigRequirementsWithSources { if let Some(incoming_apps) = other.apps.take() { if let Some(existing_apps) = self.apps.as_mut() { - merge_enablement_settings_descending(&mut existing_apps.value, incoming_apps); + merge_app_requirements_descending(&mut existing_apps.value, incoming_apps); } else { self.apps = Some(Sourced::new(incoming_apps, source)); } @@ -782,6 +834,7 @@ impl ConfigRequirementsWithSources { allowed_approvals_reviewers, allowed_sandbox_modes, allowed_web_search_modes, + allow_managed_hooks_only, feature_requirements, hooks, mcp_servers, @@ -799,6 +852,7 @@ impl ConfigRequirementsWithSources { allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value), remote_sandbox_config: None, allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value), + allow_managed_hooks_only: allow_managed_hooks_only.map(|sourced| sourced.value), feature_requirements: feature_requirements.map(|sourced| sourced.value), hooks: hooks.map(|sourced| sourced.value), mcp_servers: mcp_servers.map(|sourced| sourced.value), @@ -882,6 +936,7 @@ impl ConfigRequirementsToml { && self.allowed_sandbox_modes.is_none() && self.remote_sandbox_config.is_none() && self.allowed_web_search_modes.is_none() + && self.allow_managed_hooks_only.is_none() && self .feature_requirements .as_ref() @@ -919,6 +974,7 @@ impl TryFrom for ConfigRequirements { allowed_approvals_reviewers, allowed_sandbox_modes, allowed_web_search_modes, + allow_managed_hooks_only, feature_requirements, hooks, mcp_servers, @@ -1154,6 +1210,7 @@ impl TryFrom for ConfigRequirements { approvals_reviewer, permission_profile, web_search_mode, + allow_managed_hooks_only, feature_requirements, managed_hooks, mcp_servers, @@ -1226,6 +1283,7 @@ mod tests { allowed_sandbox_modes, remote_sandbox_config: _, allowed_web_search_modes, + allow_managed_hooks_only, feature_requirements, hooks, mcp_servers, @@ -1246,6 +1304,8 @@ mod tests { .map(|value| Sourced::new(value, RequirementSource::Unknown)), allowed_web_search_modes: allowed_web_search_modes .map(|value| Sourced::new(value, RequirementSource::Unknown)), + allow_managed_hooks_only: allow_managed_hooks_only + .map(|value| Sourced::new(value, RequirementSource::Unknown)), feature_requirements: feature_requirements .map(|value| Sourced::new(value, RequirementSource::Unknown)), hooks: hooks.map(|value| Sourced::new(value, RequirementSource::Unknown)), @@ -1262,6 +1322,32 @@ mod tests { } } + #[test] + fn deserialize_allow_managed_hooks_only() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + allow_managed_hooks_only = true + "#, + )?; + + assert_eq!(requirements.allow_managed_hooks_only, Some(true)); + assert!(!requirements.is_empty()); + Ok(()) + } + + #[test] + fn allow_managed_hooks_only_false_is_still_configured() -> Result<()> { + let requirements: ConfigRequirementsToml = from_str( + r#" + allow_managed_hooks_only = false + "#, + )?; + + assert_eq!(requirements.allow_managed_hooks_only, Some(false)); + assert!(!requirements.is_empty()); + Ok(()) + } + #[test] fn merge_unset_fields_copies_every_field_and_sets_sources() { let mut target = ConfigRequirementsWithSources::default(); @@ -1293,6 +1379,7 @@ mod tests { allowed_sandbox_modes: Some(allowed_sandbox_modes.clone()), remote_sandbox_config: None, allowed_web_search_modes: Some(allowed_web_search_modes.clone()), + allow_managed_hooks_only: Some(true), feature_requirements: Some(feature_requirements.clone()), hooks: None, mcp_servers: None, @@ -1323,6 +1410,10 @@ mod tests { allowed_web_search_modes, enforce_source.clone(), )), + allow_managed_hooks_only: Some(Sourced::new( + /*value*/ true, + enforce_source.clone(), + )), feature_requirements: Some(Sourced::new( feature_requirements, enforce_source.clone(), @@ -1365,6 +1456,7 @@ mod tests { allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -1412,6 +1504,7 @@ mod tests { allowed_approvals_reviewers: None, allowed_sandbox_modes: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -1583,6 +1676,37 @@ allowed_approvals_reviewers = ["user"] "connector_123123".to_string(), AppRequirementToml { enabled: Some(false), + tools: None, + }, + )]), + }) + ); + Ok(()) + } + + #[test] + fn deserialize_apps_tool_requirements() -> Result<()> { + let toml_str = r#" + [apps.connector_123123.tools."calendar/list_events"] + approval_mode = "approve" + "#; + let requirements: ConfigRequirementsToml = from_str(toml_str)?; + + assert_eq!( + requirements.apps, + Some(AppsRequirementsToml { + apps: BTreeMap::from([( + "connector_123123".to_string(), + AppRequirementToml { + enabled: None, + tools: Some(AppToolsRequirementsToml { + tools: BTreeMap::from([( + "calendar/list_events".to_string(), + AppToolRequirementToml { + approval_mode: Some(AppToolApproval::Approve), + }, + )]), + }), }, )]), }) @@ -1597,19 +1721,45 @@ allowed_approvals_reviewers = ["user"] .map(|(app_id, enabled)| { ( (*app_id).to_string(), - AppRequirementToml { enabled: *enabled }, + AppRequirementToml { + enabled: *enabled, + tools: None, + }, ) }) .collect(), } } + fn app_tool_requirements( + app_id: &str, + tool_name: &str, + approval_mode: AppToolApproval, + ) -> AppsRequirementsToml { + AppsRequirementsToml { + apps: BTreeMap::from([( + app_id.to_string(), + AppRequirementToml { + enabled: None, + tools: Some(AppToolsRequirementsToml { + tools: BTreeMap::from([( + tool_name.to_string(), + AppToolRequirementToml { + approval_mode: Some(approval_mode), + }, + )]), + }), + }, + )]), + } + } + #[test] - fn merge_enablement_settings_descending_unions_distinct_apps() { + fn merge_app_requirements_descending_unions_distinct_apps() { let mut merged = apps_requirements(&[("connector_high", Some(false))]); let lower = apps_requirements(&[("connector_low", Some(true))]); - merge_enablement_settings_descending(&mut merged, lower); + merge_app_requirements_descending(&mut merged, lower); assert_eq!( merged, @@ -1621,11 +1771,11 @@ allowed_approvals_reviewers = ["user"] } #[test] - fn merge_enablement_settings_descending_prefers_false_from_lower_precedence() { + fn merge_app_requirements_descending_prefers_false_from_lower_precedence() { let mut merged = apps_requirements(&[("connector_123123", Some(true))]); let lower = apps_requirements(&[("connector_123123", Some(false))]); - merge_enablement_settings_descending(&mut merged, lower); + merge_app_requirements_descending(&mut merged, lower); assert_eq!( merged, @@ -1634,11 +1784,11 @@ allowed_approvals_reviewers = ["user"] } #[test] - fn merge_enablement_settings_descending_keeps_higher_true_when_lower_is_unset() { + fn merge_app_requirements_descending_keeps_higher_true_when_lower_is_unset() { let mut merged = apps_requirements(&[("connector_123123", Some(true))]); let lower = apps_requirements(&[("connector_123123", None)]); - merge_enablement_settings_descending(&mut merged, lower); + merge_app_requirements_descending(&mut merged, lower); assert_eq!( merged, @@ -1647,11 +1797,11 @@ allowed_approvals_reviewers = ["user"] } #[test] - fn merge_enablement_settings_descending_uses_lower_value_when_higher_missing() { + fn merge_app_requirements_descending_uses_lower_value_when_higher_missing() { let mut merged = apps_requirements(&[]); let lower = apps_requirements(&[("connector_123123", Some(true))]); - merge_enablement_settings_descending(&mut merged, lower); + merge_app_requirements_descending(&mut merged, lower); assert_eq!( merged, @@ -1660,11 +1810,11 @@ allowed_approvals_reviewers = ["user"] } #[test] - fn merge_enablement_settings_descending_preserves_higher_false_when_lower_missing_app() { + fn merge_app_requirements_descending_preserves_higher_false_when_lower_missing_app() { let mut merged = apps_requirements(&[("connector_123123", Some(false))]); let lower = apps_requirements(&[]); - merge_enablement_settings_descending(&mut merged, lower); + merge_app_requirements_descending(&mut merged, lower); assert_eq!( merged, @@ -1672,6 +1822,52 @@ allowed_approvals_reviewers = ["user"] ); } + #[test] + fn merge_app_requirements_descending_preserves_higher_tool_approval_mode() { + let mut merged = app_tool_requirements( + "connector_123123", + "calendar/list_events", + AppToolApproval::Approve, + ); + let lower = app_tool_requirements( + "connector_123123", + "calendar/list_events", + AppToolApproval::Prompt, + ); + + merge_app_requirements_descending(&mut merged, lower); + + assert_eq!( + merged, + app_tool_requirements( + "connector_123123", + "calendar/list_events", + AppToolApproval::Approve, + ) + ); + } + + #[test] + fn merge_app_requirements_descending_uses_lower_tool_approval_when_higher_missing() { + let mut merged = apps_requirements(&[("connector_123123", None)]); + let lower = app_tool_requirements( + "connector_123123", + "calendar/list_events", + AppToolApproval::Approve, + ); + + merge_app_requirements_descending(&mut merged, lower); + + assert_eq!( + merged, + app_tool_requirements( + "connector_123123", + "calendar/list_events", + AppToolApproval::Approve, + ) + ); + } + #[test] fn merge_unset_fields_merges_apps_across_sources_with_enabled_evaluation() { let higher_source = RequirementSource::CloudRequirements; diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 6406f9e1ff..06993e046a 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -41,7 +41,6 @@ use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; -use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::Verbosity; use codex_protocol::config_types::WebSearchMode; @@ -146,7 +145,7 @@ pub struct ConfigToml { #[serde(default)] pub permissions: Option, - /// Deprecated optional external command to spawn for end-user notifications. + /// Optional external command to spawn for end-user notifications. #[serde(default)] pub notify: Option>, @@ -163,6 +162,9 @@ pub struct ConfigToml { /// Whether to inject the `` developer block. pub include_apps_instructions: Option, + /// Whether to inject the `` developer block. + pub include_collaboration_mode_instructions: Option, + /// Whether to inject the `` user block. pub include_environment_context: Option, @@ -176,7 +178,10 @@ pub struct ConfigToml { pub compact_prompt: Option, /// Optional commit attribution text for commit message co-author trailers. + /// This top-level setting only takes effect when `[features].codex_git_commit` + /// is enabled. /// + /// When enabled and unset, Codex uses `Codex `. /// Set to an empty string to disable automatic commit attribution. pub commit_attribution: Option, @@ -304,8 +309,9 @@ pub struct ConfigToml { /// Optionally specify a personality for the model pub personality: Option, - /// Optional explicit service tier preference for new turns (`fast` or `flex`). - pub service_tier: Option, + /// Optional explicit service tier request id for new turns (for example + /// `priority` or `flex`; legacy `fast` also works). + pub service_tier: Option, /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: Option, @@ -342,14 +348,15 @@ pub struct ConfigToml { /// active. pub experimental_realtime_start_instructions: Option, - /// Experimental / do not use. When set, app-server uses a remote thread - /// store at this endpoint instead of the local filesystem/SQLite store. - pub experimental_thread_store_endpoint: Option, - /// Experimental / do not use. When set, app-server fetches thread-scoped /// config from a remote service at this endpoint. pub experimental_thread_config_endpoint: Option, + /// Removed. Former remote thread-store endpoint setting kept only so we can + /// fail fast instead of silently falling back to local persistence. + #[schemars(skip)] + pub experimental_thread_store_endpoint: Option, + /// Experimental / do not use. Selects the thread store implementation. pub experimental_thread_store: Option, pub projects: Option>, @@ -485,9 +492,6 @@ pub struct DebugConfigLockToml { #[serde(tag = "type", rename_all = "snake_case")] pub enum ThreadStoreToml { Local {}, - Remote { - endpoint: String, - }, #[schemars(skip)] InMemory { id: String, diff --git a/codex-rs/config/src/hook_config.rs b/codex-rs/config/src/hook_config.rs index d947ebb867..869761bdaa 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -25,6 +25,8 @@ pub struct HooksToml { pub struct HookStateToml { #[serde(default, skip_serializing_if = "Option::is_none")] pub enabled: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trusted_hash: Option, } #[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -35,6 +37,10 @@ pub struct HookEventsToml { pub permission_request: Vec, #[serde(rename = "PostToolUse", default)] pub post_tool_use: Vec, + #[serde(rename = "PreCompact", default)] + pub pre_compact: Vec, + #[serde(rename = "PostCompact", default)] + pub post_compact: Vec, #[serde(rename = "SessionStart", default)] pub session_start: Vec, #[serde(rename = "UserPromptSubmit", default)] @@ -49,6 +55,8 @@ impl HookEventsToml { pre_tool_use, permission_request, post_tool_use, + pre_compact, + post_compact, session_start, user_prompt_submit, stop, @@ -56,6 +64,8 @@ impl HookEventsToml { pre_tool_use.is_empty() && permission_request.is_empty() && post_tool_use.is_empty() + && pre_compact.is_empty() + && post_compact.is_empty() && session_start.is_empty() && user_prompt_submit.is_empty() && stop.is_empty() @@ -66,6 +76,8 @@ impl HookEventsToml { pre_tool_use, permission_request, post_tool_use, + pre_compact, + post_compact, session_start, user_prompt_submit, stop, @@ -74,6 +86,8 @@ impl HookEventsToml { pre_tool_use, permission_request, post_tool_use, + pre_compact, + post_compact, session_start, user_prompt_submit, stop, @@ -84,11 +98,13 @@ impl HookEventsToml { .sum() } - pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 6] { + pub fn into_matcher_groups(self) -> [(HookEventName, Vec); 8] { [ (HookEventName::PreToolUse, self.pre_tool_use), (HookEventName::PermissionRequest, self.permission_request), (HookEventName::PostToolUse, self.post_tool_use), + (HookEventName::PreCompact, self.pre_compact), + (HookEventName::PostCompact, self.post_compact), (HookEventName::SessionStart, self.session_start), (HookEventName::UserPromptSubmit, self.user_prompt_submit), (HookEventName::Stop, self.stop), @@ -110,6 +126,8 @@ pub enum HookHandlerConfig { #[serde(rename = "command")] Command { command: String, + #[serde(default, rename = "commandWindows", alias = "command_windows")] + command_windows: Option, #[serde(default, rename = "timeout")] timeout_sec: Option, #[serde(default)] diff --git a/codex-rs/config/src/hooks_tests.rs b/codex-rs/config/src/hooks_tests.rs index 93541ee7f8..915bec7d0c 100644 --- a/codex-rs/config/src/hooks_tests.rs +++ b/codex-rs/config/src/hooks_tests.rs @@ -40,6 +40,7 @@ fn hooks_file_deserializes_existing_json_shape() { matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: "python3 /tmp/pre.py".to_string(), + command_windows: None, timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), @@ -74,6 +75,7 @@ statusMessage = "checking" matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: "python3 /tmp/pre.py".to_string(), + command_windows: None, timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), @@ -90,6 +92,7 @@ fn hooks_toml_deserializes_inline_events_and_state_map() { r#" [state."/tmp/hooks.json:pre_tool_use:0:0"] enabled = false +trusted_hash = "sha256:abc123" [[PreToolUse]] matcher = "^Bash$" @@ -109,6 +112,7 @@ command = "python3 /tmp/pre.py" matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: "python3 /tmp/pre.py".to_string(), + command_windows: None, timeout_sec: None, r#async: false, status_message: None, @@ -120,6 +124,7 @@ command = "python3 /tmp/pre.py" "/tmp/hooks.json:pre_tool_use:0:0".to_string(), super::HookStateToml { enabled: Some(false), + trusted_hash: Some("sha256:abc123".to_string()), }, )]), } @@ -152,6 +157,7 @@ command = "python3 /enterprise/place/pre.py" matcher: Some("^Bash$".to_string()), hooks: vec![HookHandlerConfig::Command { command: "python3 /enterprise/place/pre.py".to_string(), + command_windows: None, timeout_sec: None, r#async: false, status_message: None, @@ -162,3 +168,73 @@ command = "python3 /enterprise/place/pre.py" } ); } + +#[test] +fn hook_events_deserialize_windows_override_from_toml() { + let parsed: HookEventsToml = toml::from_str( + r#" +[[PreToolUse]] +matcher = "^Bash$" + +[[PreToolUse.hooks]] +type = "command" +command = "bash /enterprise/hooks/pre.sh" +command_windows = "powershell -File C:\\enterprise\\hooks\\pre.ps1" +"#, + ) + .expect("hook command Windows override TOML should deserialize"); + + assert_eq!( + parsed, + HookEventsToml { + pre_tool_use: vec![MatcherGroup { + matcher: Some("^Bash$".to_string()), + hooks: vec![HookHandlerConfig::Command { + command: "bash /enterprise/hooks/pre.sh".to_string(), + command_windows: Some( + r"powershell -File C:\enterprise\hooks\pre.ps1".to_string(), + ), + timeout_sec: None, + r#async: false, + status_message: None, + }], + }], + ..Default::default() + } + ); +} + +#[test] +fn hook_events_deserialize_camel_case_windows_override_from_toml() { + let parsed: HookEventsToml = toml::from_str( + r#" +[[PreToolUse]] +matcher = "^Bash$" + +[[PreToolUse.hooks]] +type = "command" +command = "bash /enterprise/hooks/pre.sh" +commandWindows = "powershell -File C:\\enterprise\\hooks\\pre.ps1" +"#, + ) + .expect("camelCase hook command Windows override TOML should deserialize"); + + assert_eq!( + parsed, + HookEventsToml { + pre_tool_use: vec![MatcherGroup { + matcher: Some("^Bash$".to_string()), + hooks: vec![HookHandlerConfig::Command { + command: "bash /enterprise/hooks/pre.sh".to_string(), + command_windows: Some( + r"powershell -File C:\enterprise\hooks\pre.ps1".to_string(), + ), + timeout_sec: None, + r#async: false, + status_message: None, + }], + }], + ..Default::default() + } + ); +} diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index e88c736db0..2d4c62013e 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -33,6 +33,8 @@ pub use cloud_requirements::CloudRequirementsLoader; pub use codex_app_server_protocol::ConfigLayerSource; pub use codex_utils_absolute_path::AbsolutePathBuf; pub use config_requirements::AppRequirementToml; +pub use config_requirements::AppToolRequirementToml; +pub use config_requirements::AppToolsRequirementsToml; pub use config_requirements::AppsRequirementsToml; pub use config_requirements::ConfigRequirements; pub use config_requirements::ConfigRequirementsToml; diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index f5f8ec44e5..6e98691465 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -60,6 +60,7 @@ const PROJECT_LOCAL_CONFIG_DENYLIST: &[&str] = &[ "profile", "profiles", "experimental_realtime_ws_base_url", + "otel", ]; async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> Option { @@ -626,6 +627,8 @@ struct ProjectTrustContext { project_root: AbsolutePathBuf, project_root_key: String, project_root_lookup_keys: Vec, + checkout_root: Option, + repo_root: Option, repo_root_key: Option, repo_root_lookup_keys: Option>, projects_trust: std::collections::HashMap, @@ -711,22 +714,36 @@ impl ProjectTrustContext { )), } } + + fn root_checkout_hooks_folder_for_dir(&self, dir: &AbsolutePathBuf) -> Option { + let checkout_root = self.checkout_root.as_ref()?; + let repo_root = self.repo_root.as_ref()?; + // Regular checkouts resolve both paths to the same root; linked worktrees do not. + if checkout_root == repo_root { + return None; + } + + let relative_dir = dir.as_path().strip_prefix(checkout_root.as_path()).ok()?; + Some(repo_root.join(relative_dir).join(".codex")) + } } fn project_layer_entry( dot_codex_folder: &AbsolutePathBuf, config: TomlValue, disabled_reason: Option, + hooks_config_folder_override: Option, ) -> ConfigLayerEntry { let source = ConfigLayerSource::Project { dot_codex_folder: dot_codex_folder.clone(), }; - if let Some(reason) = disabled_reason { + let entry = if let Some(reason) = disabled_reason { ConfigLayerEntry::new_disabled(source, config, reason) } else { ConfigLayerEntry::new(source, config) - } + }; + entry.with_hooks_config_folder_override(hooks_config_folder_override) } fn sanitize_project_config(config: &mut TomlValue) -> Vec { @@ -785,6 +802,7 @@ async fn project_trust_context( .first() .cloned() .unwrap_or_else(|| project_trust_key(project_root.as_path())); + let checkout_root = find_git_checkout_root(fs, cwd).await; let repo_root = resolve_root_git_project_for_trust(fs, cwd).await; let repo_root_lookup_keys = repo_root .as_ref() @@ -802,6 +820,8 @@ async fn project_trust_context( project_root, project_root_key, project_root_lookup_keys, + checkout_root, + repo_root, repo_root_key, repo_root_lookup_keys, projects_trust, @@ -943,6 +963,24 @@ async fn find_project_root( Ok(cwd.clone()) } +async fn find_git_checkout_root( + fs: &dyn ExecutorFileSystem, + cwd: &AbsolutePathBuf, +) -> Option { + let base = match fs.get_metadata(cwd, /*sandbox*/ None).await { + Ok(metadata) if metadata.is_directory => cwd.clone(), + _ => cwd.parent()?, + }; + + for dir in base.ancestors() { + let dot_git = dir.join(".git"); + if fs.get_metadata(&dot_git, /*sandbox*/ None).await.is_ok() { + return Some(dir); + } + } + None +} + struct LoadedProjectLayers { layers: Vec, startup_warnings: Vec, @@ -994,6 +1032,7 @@ async fn load_project_layers( let decision = trust_context.decision_for_dir(&dir); let disabled_reason = trust_context.disabled_reason_for_decision(&decision); + let hooks_config_folder_override = trust_context.root_checkout_hooks_folder_for_dir(&dir); let dot_codex_normalized = normalize_path(dot_codex_abs.as_path()).unwrap_or_else(|_| dot_codex_abs.to_path_buf()); if dot_codex_abs == codex_home_abs || dot_codex_normalized == codex_home_normalized { @@ -1018,6 +1057,7 @@ async fn load_project_layers( &dot_codex_abs, TomlValue::Table(toml::map::Map::new()), disabled_reason.clone(), + hooks_config_folder_override.clone(), )); continue; } @@ -1026,13 +1066,25 @@ async fn load_project_layers( let ignored_project_config_keys = sanitize_project_config(&mut config); let config = resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?; + let config = merge_root_checkout_project_hooks( + fs, + config, + hooks_config_folder_override.as_ref(), + decision.is_trusted(), + ) + .await?; if disabled_reason.is_none() && !ignored_project_config_keys.is_empty() { startup_warnings.push(project_ignored_config_keys_warning( &dot_codex_abs, &ignored_project_config_keys, )); } - let entry = project_layer_entry(&dot_codex_abs, config, disabled_reason.clone()); + let entry = project_layer_entry( + &dot_codex_abs, + config, + disabled_reason.clone(), + hooks_config_folder_override.clone(), + ); layers.push(entry); } Err(err) => { @@ -1040,10 +1092,18 @@ async fn load_project_layers( // If there is no config.toml file, record an empty entry // for this project layer, as this may still have subfolders // that are significant in the overall ConfigLayerStack. + let config = merge_root_checkout_project_hooks( + fs, + TomlValue::Table(toml::map::Map::new()), + hooks_config_folder_override.as_ref(), + decision.is_trusted(), + ) + .await?; layers.push(project_layer_entry( &dot_codex_abs, - TomlValue::Table(toml::map::Map::new()), + config, disabled_reason, + hooks_config_folder_override, )); } else { let config_file_display = config_file.as_path().display(); @@ -1061,6 +1121,64 @@ async fn load_project_layers( startup_warnings, }) } + +/// For linked worktrees, preserve ordinary worktree-local project config while +/// replacing only hook declarations with the matching root-checkout layer. +async fn merge_root_checkout_project_hooks( + fs: &dyn ExecutorFileSystem, + mut config: TomlValue, + hooks_config_folder_override: Option<&AbsolutePathBuf>, + is_trusted: bool, +) -> io::Result { + let Some(hooks_config_folder) = hooks_config_folder_override else { + return Ok(config); + }; + let hooks_config_file = hooks_config_folder.join(CONFIG_TOML_FILE); + let root_config = match fs + .read_file_text(&hooks_config_file, /*sandbox*/ None) + .await + { + Ok(contents) => { + let parsed: TomlValue = match toml::from_str(&contents) { + Ok(parsed) => parsed, + Err(err) => { + if is_trusted { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "Error parsing project hooks config file {}: {err}", + hooks_config_file.as_path().display() + ), + )); + } + TomlValue::Table(toml::map::Map::new()) + } + }; + resolve_relative_paths_in_config_toml(parsed, hooks_config_folder.as_path())? + } + Err(err) if err.kind() == io::ErrorKind::NotFound => { + TomlValue::Table(toml::map::Map::new()) + } + Err(err) => { + return Err(io::Error::new( + err.kind(), + format!( + "Failed to read project hooks config file {}: {err}", + hooks_config_file.as_path().display() + ), + )); + } + }; + + let Some(config_table) = config.as_table_mut() else { + return Ok(config); + }; + config_table.remove("hooks"); + if let Some(hooks) = root_config.get("hooks") { + config_table.insert("hooks".to_string(), hooks.clone()); + } + Ok(config) +} /// The legacy mechanism for specifying admin-enforced configuration is to read /// from a file like `/etc/codex/managed_config.toml` that has the same /// structure as `config.toml` where fields like `approval_policy` can specify diff --git a/codex-rs/config/src/profile_toml.rs b/codex-rs/config/src/profile_toml.rs index f6f63191b5..11d3a6a403 100644 --- a/codex-rs/config/src/profile_toml.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -7,11 +7,11 @@ use crate::config_toml::ToolsToml; use crate::types::AnalyticsConfigToml; use crate::types::ApprovalsReviewer; use crate::types::Personality; +use crate::types::SessionPickerViewMode; use crate::types::WindowsToml; use codex_features::FeaturesToml; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; -use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Verbosity; use codex_protocol::config_types::WebSearchMode; use codex_protocol::openai_models::ReasoningEffort; @@ -23,8 +23,9 @@ use codex_protocol::protocol::AskForApproval; #[schemars(deny_unknown_fields)] pub struct ConfigProfile { pub model: Option, - /// Optional explicit service tier preference for new turns (`fast` or `flex`). - pub service_tier: Option, + /// Optional explicit service tier request id for new turns (for example + /// `priority` or `flex`; legacy `fast` also works). + pub service_tier: Option, /// The key in the `model_providers` map identifying the /// [`ModelProviderInfo`] to use. pub model_provider: Option, @@ -56,6 +57,7 @@ pub struct ConfigProfile { pub include_apply_patch_tool: Option, pub include_permissions_instructions: Option, pub include_apps_instructions: Option, + pub include_collaboration_mode_instructions: Option, pub include_environment_context: Option, pub experimental_use_unified_exec_tool: Option, pub experimental_use_freeform_apply_patch: Option, @@ -63,6 +65,9 @@ pub struct ConfigProfile { pub tools: Option, pub web_search: Option, pub analytics: Option, + /// TUI settings scoped to this profile. + #[serde(default)] + pub tui: Option, #[serde(default)] pub windows: Option, /// Optional feature toggles scoped to this profile. @@ -73,6 +78,16 @@ pub struct ConfigProfile { pub oss_provider: Option, } +/// TUI settings supported inside a named profile. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars(deny_unknown_fields)] +pub struct ProfileTui { + /// Preferred layout for resume/fork session picker results. + #[serde(default)] + pub session_picker_view: Option, +} + impl From for codex_app_server_protocol::Profile { fn from(config_profile: ConfigProfile) -> Self { Self { diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs index 715822fbfe..df0de3b1cd 100644 --- a/codex-rs/config/src/schema.rs +++ b/codex-rs/config/src/schema.rs @@ -43,6 +43,15 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { ); continue; } + if feature.id == codex_features::Feature::NetworkProxy { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } validation .properties .insert(feature.key.to_string(), schema_gen.subschema_for::()); diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index fc5ec79971..e718ecae67 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -66,6 +66,7 @@ pub struct ConfigLayerEntry { pub raw_toml: Option, pub version: String, pub disabled_reason: Option, + hooks_config_folder_override: Option, } impl ConfigLayerEntry { @@ -77,6 +78,7 @@ impl ConfigLayerEntry { raw_toml: None, version, disabled_reason: None, + hooks_config_folder_override: None, } } @@ -88,6 +90,7 @@ impl ConfigLayerEntry { raw_toml: Some(raw_toml), version, disabled_reason: None, + hooks_config_folder_override: None, } } @@ -103,6 +106,7 @@ impl ConfigLayerEntry { raw_toml: None, version, disabled_reason: Some(disabled_reason.into()), + hooks_config_folder_override: None, } } @@ -114,6 +118,14 @@ impl ConfigLayerEntry { self.raw_toml.as_deref() } + pub(crate) fn with_hooks_config_folder_override( + mut self, + hooks_config_folder_override: Option, + ) -> Self { + self.hooks_config_folder_override = hooks_config_folder_override; + self + } + pub fn metadata(&self) -> ConfigLayerMetadata { ConfigLayerMetadata { name: self.name.clone(), @@ -142,6 +154,18 @@ impl ConfigLayerEntry { ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None, } } + + /// Returns the `.codex/` folder that should be used for hook declarations. + /// + /// Project layers normally use their own config folder. Linked Git worktrees + /// can instead point hook discovery at the matching folder from the root + /// checkout while the rest of the project config still comes from the + /// worktree. + pub fn hooks_config_folder(&self) -> Option { + self.hooks_config_folder_override + .clone() + .or_else(|| self.config_folder()) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -237,28 +261,32 @@ impl ConfigLayerStack { /// replaced; otherwise, it is inserted into the stack at the appropriate /// position based on precedence rules. pub fn with_user_config(&self, config_toml: &AbsolutePathBuf, user_config: TomlValue) -> Self { - let user_layer = ConfigLayerEntry::new( + self.with_user_layer(Some(ConfigLayerEntry::new( ConfigLayerSource::User { file: config_toml.clone(), }, user_config, - ); + ))) + } + /// Returns a new stack with the user layer copied from `other`, preserving + /// every non-user layer already present in this stack. + pub fn with_user_layer_from(&self, other: &Self) -> Self { + self.with_user_layer(other.get_user_layer().cloned()) + } + + fn with_user_layer(&self, user_layer: Option) -> Self { let mut layers = self.layers.clone(); - match self.user_layer_index { - Some(index) => { + let user_layer_index = match (self.user_layer_index, user_layer) { + (Some(index), Some(user_layer)) => { layers[index] = user_layer; - Self { - layers, - user_layer_index: self.user_layer_index, - requirements: self.requirements.clone(), - requirements_toml: self.requirements_toml.clone(), - ignore_user_and_project_exec_policy_rules: self - .ignore_user_and_project_exec_policy_rules, - startup_warnings: self.startup_warnings.clone(), - } + Some(index) } - None => { + (Some(index), None) => { + layers.remove(index); + None + } + (None, Some(user_layer)) => { let user_layer_index = match layers .iter() .position(|layer| layer.name.precedence() > user_layer.name.precedence()) @@ -272,16 +300,18 @@ impl ConfigLayerStack { layers.len() - 1 } }; - Self { - layers, - user_layer_index: Some(user_layer_index), - requirements: self.requirements.clone(), - requirements_toml: self.requirements_toml.clone(), - ignore_user_and_project_exec_policy_rules: self - .ignore_user_and_project_exec_policy_rules, - startup_warnings: self.startup_warnings.clone(), - } + Some(user_layer_index) } + (None, None) => None, + }; + Self { + layers, + user_layer_index, + requirements: self.requirements.clone(), + requirements_toml: self.requirements_toml.clone(), + ignore_user_and_project_exec_policy_rules: self + .ignore_user_and_project_exec_policy_rules, + startup_warnings: self.startup_warnings.clone(), } } diff --git a/codex-rs/config/src/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index b23322a538..90d82da933 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -104,6 +104,10 @@ pub struct TuiGlobalKeymap { pub toggle_shortcuts: Option, /// Toggle Vim mode for the composer input. pub toggle_vim_mode: Option, + /// Toggle Fast mode. + pub toggle_fast_mode: Option, + /// Toggle raw scrollback mode for copy-friendly transcript selection. + pub toggle_raw_output: Option, } /// Chat context keybindings. @@ -169,6 +173,8 @@ pub struct TuiEditorKeymap { pub delete_forward_word: Option, /// Kill text from cursor to line start. pub kill_line_start: Option, + /// Kill the current line. + pub kill_whole_line: Option, /// Kill text from cursor to line end. pub kill_line_end: Option, /// Yank the kill buffer. @@ -488,6 +494,7 @@ fn normalize_key_name(key: &str, original: &str) -> Result { | "page-up" | "page-down" | "space" + | "minus" ) { return Ok(alias.to_string()); } @@ -502,7 +509,7 @@ fn normalize_key_name(key: &str, original: &str) -> Result { Err(format!( "unknown key `{key}` in keybinding `{original}`. \ Use a printable character (for example `a`), function keys (`f1`-`f12`), \ -or one of: enter, tab, backspace, esc, delete, arrows, home/end, page-up/page-down, space.\n\ +or one of: enter, tab, backspace, esc, delete, arrows, home/end, page-up/page-down, space, minus.\n\ See the Codex keymap documentation for supported actions and examples." )) } @@ -510,6 +517,7 @@ See the Codex keymap documentation for supported actions and examples." #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; #[test] fn misplaced_action_at_keymap_root_is_rejected() { @@ -575,4 +583,30 @@ mod tests { let keymap: TuiKeymap = toml::from_str(toml_input).expect("valid config"); assert!(keymap.global.open_transcript.is_some()); } + + #[test] + fn minus_bindings_under_global_context_are_accepted() { + for (spec, expected) in [ + ( + "minus", + KeybindingsSpec::One(KeybindingSpec("minus".to_string())), + ), + ( + "alt-minus", + KeybindingsSpec::One(KeybindingSpec("alt-minus".to_string())), + ), + ] { + let toml_input = format!( + r#" + [global] + open_transcript = "{spec}" + "# + ); + let keymap: TuiKeymap = toml::from_str(&toml_input).expect("valid config"); + let mut expected_keymap = TuiKeymap::default(); + expected_keymap.global.open_transcript = Some(expected); + + assert_eq!(keymap, expected_keymap); + } + } } diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index b856367a66..dfc81852ea 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -57,6 +57,30 @@ const fn default_enabled() -> bool { true } +/// Preferred layout for the resume/fork session picker. +#[derive(Serialize, Deserialize, Debug, Default, Copy, Clone, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum SessionPickerViewMode { + Comfortable, + #[default] + Dense, +} + +impl SessionPickerViewMode { + pub const fn as_str(self) -> &'static str { + match self { + Self::Comfortable => "comfortable", + Self::Dense => "dense", + } + } +} + +impl fmt::Display for SessionPickerViewMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + /// Determine where Codex should store CLI auth credentials. #[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "lowercase")] @@ -490,6 +514,12 @@ pub struct OtelConfigToml { /// Optional metrics exporter pub metrics_exporter: Option, + + /// Attributes to add to every exported trace span. + pub span_attributes: Option>, + + /// Semicolon-separated `key:value` fields to upsert into W3C tracestate members. + pub tracestate: Option>>, } /// Effective OTEL settings after defaults are applied. @@ -500,6 +530,8 @@ pub struct OtelConfig { pub exporter: OtelExporterKind, pub trace_exporter: OtelExporterKind, pub metrics_exporter: OtelExporterKind, + pub span_attributes: BTreeMap, + pub tracestate: BTreeMap>, } impl Default for OtelConfig { @@ -510,6 +542,8 @@ impl Default for OtelConfig { exporter: OtelExporterKind::None, trace_exporter: OtelExporterKind::None, metrics_exporter: OtelExporterKind::Statsig, + span_attributes: BTreeMap::new(), + tracestate: BTreeMap::new(), } } } @@ -565,6 +599,16 @@ impl fmt::Display for NotificationCondition { } } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] +#[serde(rename_all = "kebab-case")] +pub enum TuiPetAnchor { + /// Anchor the pet to the bottom of the current TUI composer viewport. + #[default] + Composer, + /// Anchor the pet to the physical bottom of the terminal screen. + ScreenBottom, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct TuiNotificationSettings { @@ -617,6 +661,11 @@ pub struct Tui { #[serde(default)] pub vim_mode_default: bool, + /// Start the TUI in raw scrollback mode for copy-friendly transcript output. + /// Defaults to `false`. + #[serde(default)] + pub raw_output_mode: bool, + /// Controls whether the TUI uses the terminal's alternate screen buffer. /// /// - `auto` (default): Disable alternate screen in Zellij, enable elsewhere. @@ -656,6 +705,22 @@ pub struct Tui { #[serde(default)] pub theme: Option, + /// Pet id to preselect in the terminal pet picker. + /// + /// Custom pet ids resolve against CODEX_HOME/pets//pet.json. + #[serde(default)] + pub pet: Option, + + /// Where the terminal pet should anchor vertically. + /// + /// Defaults to `composer`, which follows the current TUI composer viewport. + #[serde(default)] + pub pet_anchor: TuiPetAnchor, + + /// Preferred layout for resume/fork session picker results. + #[serde(default)] + pub session_picker_view: Option, + /// Keybinding overrides for the TUI. /// /// This supports rebinding selected actions globally and by context. diff --git a/codex-rs/connectors/Cargo.toml b/codex-rs/connectors/Cargo.toml index 9cd2428a71..1ebdae32dc 100644 --- a/codex-rs/connectors/Cargo.toml +++ b/codex-rs/connectors/Cargo.toml @@ -11,8 +11,15 @@ workspace = true anyhow = { workspace = true } codex-app-server-protocol = { workspace = true } serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha1 = { workspace = true } +tracing = { workspace = true } urlencoding = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } + +[lib] +doctest = false diff --git a/codex-rs/connectors/src/directory_cache.rs b/codex-rs/connectors/src/directory_cache.rs new file mode 100644 index 0000000000..581193b87c --- /dev/null +++ b/codex-rs/connectors/src/directory_cache.rs @@ -0,0 +1,112 @@ +use std::path::PathBuf; + +use codex_app_server_protocol::AppInfo; +use serde::Deserialize; +use serde::Serialize; +use sha1::Digest; +use sha1::Sha1; +use tracing::warn; + +use crate::ConnectorDirectoryCacheKey; + +pub(crate) const CONNECTOR_DIRECTORY_DISK_CACHE_SCHEMA_VERSION: u8 = 1; +const CONNECTOR_DIRECTORY_DISK_CACHE_DIR: &str = "cache/codex_app_directory"; + +#[derive(Clone)] +pub struct ConnectorDirectoryCacheContext { + pub(crate) codex_home: PathBuf, + pub(crate) cache_key: ConnectorDirectoryCacheKey, +} + +impl ConnectorDirectoryCacheContext { + pub fn new(codex_home: PathBuf, cache_key: ConnectorDirectoryCacheKey) -> Self { + Self { + codex_home, + cache_key, + } + } + + pub(crate) fn cache_path(&self) -> PathBuf { + let cache_key_json = serde_json::to_string(&self.cache_key).unwrap_or_default(); + let cache_key_hash = sha1_hex(&cache_key_json); + self.codex_home + .join(CONNECTOR_DIRECTORY_DISK_CACHE_DIR) + .join(format!("{cache_key_hash}.json")) + } +} + +pub(crate) enum CachedConnectorDirectoryDiskLoad { + Hit { connectors: Vec }, + Missing, + Invalid, +} + +pub(crate) fn load_cached_directory_connectors_from_disk( + cache_context: &ConnectorDirectoryCacheContext, +) -> CachedConnectorDirectoryDiskLoad { + let cache_path = cache_context.cache_path(); + let bytes = match std::fs::read(&cache_path) { + Ok(bytes) => bytes, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + return CachedConnectorDirectoryDiskLoad::Missing; + } + Err(err) => { + warn!( + cache_path = %cache_path.display(), + "failed to read connector directory disk cache: {err}" + ); + return CachedConnectorDirectoryDiskLoad::Invalid; + } + }; + let cache: ConnectorDirectoryDiskCache = match serde_json::from_slice(&bytes) { + Ok(cache) => cache, + Err(err) => { + warn!( + cache_path = %cache_path.display(), + "failed to parse connector directory disk cache: {err}" + ); + let _ = std::fs::remove_file(cache_path); + return CachedConnectorDirectoryDiskLoad::Invalid; + } + }; + if cache.schema_version != CONNECTOR_DIRECTORY_DISK_CACHE_SCHEMA_VERSION { + let _ = std::fs::remove_file(cache_path); + return CachedConnectorDirectoryDiskLoad::Invalid; + } + + CachedConnectorDirectoryDiskLoad::Hit { + connectors: cache.connectors, + } +} + +pub(crate) fn write_cached_directory_connectors_to_disk( + cache_context: &ConnectorDirectoryCacheContext, + connectors: &[AppInfo], +) { + let cache_path = cache_context.cache_path(); + if let Some(parent) = cache_path.parent() + && std::fs::create_dir_all(parent).is_err() + { + return; + } + let Ok(bytes) = serde_json::to_vec_pretty(&ConnectorDirectoryDiskCache { + schema_version: CONNECTOR_DIRECTORY_DISK_CACHE_SCHEMA_VERSION, + connectors: connectors.to_vec(), + }) else { + return; + }; + let _ = std::fs::write(cache_path, bytes); +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ConnectorDirectoryDiskCache { + schema_version: u8, + connectors: Vec, +} + +fn sha1_hex(value: &str) -> String { + let mut hasher = Sha1::new(); + hasher.update(value.as_bytes()); + let sha1 = hasher.finalize(); + format!("{sha1:x}") +} diff --git a/codex-rs/connectors/src/lib.rs b/codex-rs/connectors/src/lib.rs index e6260d0e1d..c2bf891115 100644 --- a/codex-rs/connectors/src/lib.rs +++ b/codex-rs/connectors/src/lib.rs @@ -9,23 +9,27 @@ use codex_app_server_protocol::AppBranding; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppMetadata; use serde::Deserialize; +use serde::Serialize; pub mod accessible; +mod directory_cache; pub mod filter; pub mod merge; pub mod metadata; +pub use directory_cache::ConnectorDirectoryCacheContext; + pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600); -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct AllConnectorsCacheKey { +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ConnectorDirectoryCacheKey { chatgpt_base_url: String, account_id: Option, chatgpt_user_id: Option, is_workspace_account: bool, } -impl AllConnectorsCacheKey { +impl ConnectorDirectoryCacheKey { pub fn new( chatgpt_base_url: String, account_id: Option, @@ -42,13 +46,13 @@ impl AllConnectorsCacheKey { } #[derive(Clone)] -struct CachedAllConnectors { - key: AllConnectorsCacheKey, +struct CachedConnectorDirectory { + key: ConnectorDirectoryCacheKey, expires_at: Instant, connectors: Vec, } -static ALL_CONNECTORS_CACHE: LazyLock>> = +static CONNECTOR_DIRECTORY_CACHE: LazyLock>> = LazyLock::new(|| StdMutex::new(None)); #[derive(Debug, Deserialize)] @@ -76,26 +80,54 @@ pub struct DirectoryApp { visibility: Option, } -pub fn cached_all_connectors(cache_key: &AllConnectorsCacheKey) -> Option> { - let mut cache_guard = ALL_CONNECTORS_CACHE - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let now = Instant::now(); - - if let Some(cached) = cache_guard.as_ref() { - if now < cached.expires_at && cached.key == *cache_key { - return Some(cached.connectors.clone()); - } - if now >= cached.expires_at { - *cache_guard = None; - } +pub fn cached_directory_connectors( + cache_context: &ConnectorDirectoryCacheContext, +) -> Option> { + if let Some(cached_connectors) = cached_directory_connectors_in_memory(&cache_context.cache_key) + { + return Some(cached_connectors); } + let directory_cache::CachedConnectorDirectoryDiskLoad::Hit { connectors } = + directory_cache::load_cached_directory_connectors_from_disk(cache_context) + else { + return None; + }; + write_cached_directory_connectors_in_memory( + cache_context.cache_key.clone(), + &connectors, + Duration::ZERO, + ); + Some(connectors) +} + +fn cached_directory_connectors_in_memory( + cache_key: &ConnectorDirectoryCacheKey, +) -> Option> { + let cache_guard = CONNECTOR_DIRECTORY_CACHE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + cache_guard + .as_ref() + .filter(|cached| cached.key == *cache_key) + .map(|cached| cached.connectors.clone()) +} + +fn unexpired_directory_connectors_in_memory( + cache_key: &ConnectorDirectoryCacheKey, +) -> Option> { + let cache_guard = CONNECTOR_DIRECTORY_CACHE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let cached = cache_guard.as_ref()?; + if cached.key == *cache_key && Instant::now() < cached.expires_at { + return Some(cached.connectors.clone()); + } None } pub async fn list_all_connectors_with_options( - cache_key: AllConnectorsCacheKey, + cache_context: ConnectorDirectoryCacheContext, is_workspace_account: bool, force_refetch: bool, mut fetch_page: F, @@ -104,7 +136,10 @@ where F: FnMut(String) -> Fut, Fut: Future>, { - if !force_refetch && let Some(cached_connectors) = cached_all_connectors(&cache_key) { + if !force_refetch + && let Some(cached_connectors) = + unexpired_directory_connectors_in_memory(&cache_context.cache_key) + { return Ok(cached_connectors); } @@ -132,17 +167,33 @@ where .cmp(&right.name) .then_with(|| left.id.cmp(&right.id)) }); - write_cached_all_connectors(cache_key, &connectors); + write_cached_directory_connectors(&cache_context, &connectors); Ok(connectors) } -fn write_cached_all_connectors(cache_key: AllConnectorsCacheKey, connectors: &[AppInfo]) { - let mut cache_guard = ALL_CONNECTORS_CACHE +fn write_cached_directory_connectors( + cache_context: &ConnectorDirectoryCacheContext, + connectors: &[AppInfo], +) { + write_cached_directory_connectors_in_memory( + cache_context.cache_key.clone(), + connectors, + CONNECTORS_CACHE_TTL, + ); + directory_cache::write_cached_directory_connectors_to_disk(cache_context, connectors); +} + +fn write_cached_directory_connectors_in_memory( + cache_key: ConnectorDirectoryCacheKey, + connectors: &[AppInfo], + ttl: Duration, +) { + let mut cache_guard = CONNECTOR_DIRECTORY_CACHE .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - *cache_guard = Some(CachedAllConnectors { + *cache_guard = Some(CachedConnectorDirectory { key: cache_key, - expires_at: Instant::now() + CONNECTORS_CACHE_TTL, + expires_at: Instant::now() + ttl, connectors: connectors.to_vec(), }); } @@ -417,12 +468,13 @@ mod tests { use std::sync::Mutex; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; + use tempfile::TempDir; - static ALL_CONNECTORS_CACHE_TEST_LOCK: LazyLock> = + static CONNECTOR_DIRECTORY_CACHE_TEST_LOCK: LazyLock> = LazyLock::new(|| tokio::sync::Mutex::new(())); - fn cache_key(id: &str) -> AllConnectorsCacheKey { - AllConnectorsCacheKey::new( + fn cache_key(id: &str) -> ConnectorDirectoryCacheKey { + ConnectorDirectoryCacheKey::new( "https://chatgpt.example".to_string(), Some(format!("account-{id}")), Some(format!("user-{id}")), @@ -430,6 +482,17 @@ mod tests { ) } + fn cache_context(codex_home: &TempDir, id: &str) -> ConnectorDirectoryCacheContext { + ConnectorDirectoryCacheContext::new(codex_home.path().to_path_buf(), cache_key(id)) + } + + fn clear_directory_memory_cache() { + let mut cache_guard = CONNECTOR_DIRECTORY_CACHE + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + *cache_guard = None; + } + fn app(id: &str, name: &str) -> DirectoryApp { DirectoryApp { id: id.to_string(), @@ -450,15 +513,16 @@ mod tests { clippy::await_holding_invalid_type, reason = "test serializes access to the shared connector cache for its full duration" )] - async fn list_all_connectors_uses_shared_cache() -> anyhow::Result<()> { - let _cache_guard = ALL_CONNECTORS_CACHE_TEST_LOCK.lock().await; + async fn list_all_connectors_uses_shared_directory_cache() -> anyhow::Result<()> { + let _cache_guard = CONNECTOR_DIRECTORY_CACHE_TEST_LOCK.lock().await; let calls = Arc::new(AtomicUsize::new(0)); let call_counter = Arc::clone(&calls); - let key = cache_key("shared"); + let codex_home = TempDir::new()?; + let cache_context = cache_context(&codex_home, "shared"); let first = list_all_connectors_with_options( - key.clone(), + cache_context.clone(), /*is_workspace_account*/ false, /*force_refetch*/ false, move |_path| { @@ -475,7 +539,7 @@ mod tests { .await?; let second = list_all_connectors_with_options( - key, + cache_context, /*is_workspace_account*/ false, /*force_refetch*/ false, move |_path| async move { @@ -495,14 +559,15 @@ mod tests { reason = "test serializes access to the shared connector cache for its full duration" )] async fn list_all_connectors_merges_and_normalizes_directory_apps() -> anyhow::Result<()> { - let _cache_guard = ALL_CONNECTORS_CACHE_TEST_LOCK.lock().await; + let _cache_guard = CONNECTOR_DIRECTORY_CACHE_TEST_LOCK.lock().await; - let key = cache_key("merged"); + let codex_home = TempDir::new()?; + let cache_context = cache_context(&codex_home, "merged"); let calls = Arc::new(AtomicUsize::new(0)); let call_counter = Arc::clone(&calls); let connectors = list_all_connectors_with_options( - key, + cache_context, /*is_workspace_account*/ true, /*force_refetch*/ true, move |path| { @@ -566,6 +631,134 @@ mod tests { Ok(()) } + #[tokio::test] + #[expect( + clippy::await_holding_invalid_type, + reason = "test serializes access to the shared connector cache for its full duration" + )] + async fn cached_directory_connectors_reads_directory_disk_cache() -> anyhow::Result<()> { + let _cache_guard = CONNECTOR_DIRECTORY_CACHE_TEST_LOCK.lock().await; + + let codex_home = TempDir::new()?; + let cache_context = cache_context(&codex_home, "disk"); + let calls = Arc::new(AtomicUsize::new(0)); + let call_counter = Arc::clone(&calls); + + let first = list_all_connectors_with_options( + cache_context.clone(), + /*is_workspace_account*/ false, + /*force_refetch*/ false, + move |_path| { + let call_counter = Arc::clone(&call_counter); + async move { + call_counter.fetch_add(1, Ordering::SeqCst); + Ok(DirectoryListResponse { + apps: vec![app("alpha", "Alpha")], + next_token: None, + }) + } + }, + ) + .await?; + + clear_directory_memory_cache(); + + let second = cached_directory_connectors(&cache_context).expect("disk cache should load"); + + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert_eq!(first, second); + Ok(()) + } + + #[tokio::test] + #[expect( + clippy::await_holding_invalid_type, + reason = "test serializes access to the shared connector cache for its full duration" + )] + async fn list_all_connectors_refreshes_when_only_directory_disk_cache_exists() + -> anyhow::Result<()> { + let _cache_guard = CONNECTOR_DIRECTORY_CACHE_TEST_LOCK.lock().await; + + let codex_home = TempDir::new()?; + let cache_context = cache_context(&codex_home, "disk-refresh"); + let calls = Arc::new(AtomicUsize::new(0)); + let call_counter = Arc::clone(&calls); + + list_all_connectors_with_options( + cache_context.clone(), + /*is_workspace_account*/ false, + /*force_refetch*/ false, + move |_path| { + let call_counter = Arc::clone(&call_counter); + async move { + call_counter.fetch_add(1, Ordering::SeqCst); + Ok(DirectoryListResponse { + apps: vec![app("alpha", "Alpha")], + next_token: None, + }) + } + }, + ) + .await?; + + clear_directory_memory_cache(); + let mut cached_expected = directory_app_to_app_info(app("alpha", "Alpha")); + cached_expected.install_url = Some(connector_install_url( + &cached_expected.name, + &cached_expected.id, + )); + assert_eq!( + cached_directory_connectors(&cache_context), + Some(vec![cached_expected]) + ); + let refreshed_calls = Arc::clone(&calls); + + let refreshed = list_all_connectors_with_options( + cache_context, + /*is_workspace_account*/ false, + /*force_refetch*/ false, + move |_path| { + let call_counter = Arc::clone(&refreshed_calls); + async move { + call_counter.fetch_add(1, Ordering::SeqCst); + Ok(DirectoryListResponse { + apps: vec![app("beta", "Beta")], + next_token: None, + }) + } + }, + ) + .await?; + + let mut expected = directory_app_to_app_info(app("beta", "Beta")); + expected.install_url = Some(connector_install_url(&expected.name, &expected.id)); + assert_eq!(calls.load(Ordering::SeqCst), 2); + assert_eq!(refreshed, vec![expected]); + Ok(()) + } + + #[tokio::test] + async fn cached_directory_connectors_drops_stale_disk_schema() -> anyhow::Result<()> { + let _cache_guard = CONNECTOR_DIRECTORY_CACHE_TEST_LOCK.lock().await; + + clear_directory_memory_cache(); + let codex_home = TempDir::new()?; + let cache_context = cache_context(&codex_home, "stale-schema"); + let cache_path = cache_context.cache_path(); + std::fs::create_dir_all(cache_path.parent().expect("cache parent"))?; + std::fs::write( + &cache_path, + serde_json::to_vec_pretty(&serde_json::json!({ + "schema_version": 0, + "connectors": [], + }))?, + )?; + + assert_eq!(cached_directory_connectors(&cache_context), None); + assert!(!cache_path.exists()); + Ok(()) + } + #[tokio::test] async fn list_directory_connectors_omits_tier_for_all_pages() -> anyhow::Result<()> { let requested_paths: Arc>> = Arc::new(Mutex::new(Vec::new())); diff --git a/codex-rs/core-api/Cargo.toml b/codex-rs/core-api/Cargo.toml index 0cc084650c..998da39ce7 100644 --- a/codex-rs/core-api/Cargo.toml +++ b/codex-rs/core-api/Cargo.toml @@ -8,6 +8,7 @@ version.workspace = true doctest = false name = "codex_core_api" path = "src/lib.rs" +test = false [lints] workspace = true @@ -18,6 +19,7 @@ codex-arg0 = { workspace = true } codex-analytics = { workspace = true } codex-config = { workspace = true } codex-core = { workspace = true } +codex-extension-api = { workspace = true } codex-exec-server = { workspace = true } codex-features = { workspace = true } codex-login = { workspace = true } diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index dca169ed2b..04ebaf8e7e 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -18,15 +18,18 @@ pub use codex_config::types::ModelAvailabilityNuxConfig; pub use codex_config::types::Notice; pub use codex_config::types::OAuthCredentialsStoreMode; pub use codex_config::types::OtelConfig; +pub use codex_config::types::SessionPickerViewMode; pub use codex_config::types::ToolSuggestConfig; pub use codex_config::types::TuiKeymap; pub use codex_config::types::TuiNotificationSettings; +pub use codex_config::types::TuiPetAnchor; pub use codex_config::types::UriBasedFileOpener; pub use codex_core::CodexThread; pub use codex_core::ForkSnapshot; pub use codex_core::McpManager; pub use codex_core::NewThread; pub use codex_core::StartThreadOptions; +pub use codex_core::StateDbHandle; pub use codex_core::ThreadManager; pub use codex_core::ThreadShutdownReport; pub use codex_core::config::Config; @@ -37,11 +40,13 @@ pub use codex_core::config::Permissions; pub use codex_core::config::TerminalResizeReflowConfig; pub use codex_core::config::ThreadStoreConfig; pub use codex_core::config::find_codex_home; +pub use codex_core::init_state_db; +pub use codex_core::resolve_installation_id; pub use codex_core::skills::SkillsManager; pub use codex_core::thread_store_from_config; pub use codex_exec_server::EnvironmentManager; -pub use codex_exec_server::EnvironmentManagerArgs; pub use codex_exec_server::ExecServerRuntimePaths; +pub use codex_extension_api::empty_extension_registry; pub use codex_features::Feature; pub use codex_features::Features; pub use codex_login::AuthManager; diff --git a/codex-rs/core-plugins/Cargo.toml b/codex-rs/core-plugins/Cargo.toml index db3059f2b0..352d6e5714 100644 --- a/codex-rs/core-plugins/Cargo.toml +++ b/codex-rs/core-plugins/Cargo.toml @@ -20,6 +20,7 @@ codex-config = { workspace = true } codex-core-skills = { workspace = true } codex-exec-server = { workspace = true } codex-git-utils = { workspace = true } +codex-hooks = { workspace = true } codex-login = { workspace = true } codex-model-provider = { workspace = true } codex-otel = { workspace = true } diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 9ff6bc24c5..b724699039 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -26,6 +26,7 @@ pub const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[ "gmail@openai-curated", "google-calendar@openai-curated", "google-drive@openai-curated", + "openai-developers@openai-curated", "canva@openai-curated", "teams@openai-curated", "sharepoint@openai-curated", diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index b07b7da3e8..f348a3414d 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -569,6 +569,7 @@ async fn load_plugin( loaded_plugin.skill_roots = plugin_skill_roots(&plugin_root, manifest_paths); let resolved_skills = load_plugin_skills( &plugin_root, + &loaded_plugin_id, manifest_paths, restriction_product, skill_config_rules, @@ -647,6 +648,7 @@ impl ResolvedPluginSkills { pub async fn load_plugin_skills( plugin_root: &AbsolutePathBuf, + plugin_id: &PluginId, manifest_paths: &PluginManifestPaths, restriction_product: Option, skill_config_rules: &SkillConfigRules, @@ -657,6 +659,7 @@ pub async fn load_plugin_skills( path, scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some(plugin_id.as_key()), }) .collect::>(); let outcome = load_skills_from_roots(roots).await; diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index aecbd76e5c..2df35b401f 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -6,6 +6,7 @@ use crate::loader::configured_curated_plugin_ids_from_codex_home; use crate::loader::curated_plugin_cache_version; use crate::loader::installed_plugin_telemetry_metadata; use crate::loader::load_plugin_apps; +use crate::loader::load_plugin_hooks; use crate::loader::load_plugin_mcp_servers; use crate::loader::load_plugin_skills; use crate::loader::load_plugins_from_layer_stack; @@ -54,6 +55,7 @@ use codex_config::set_user_plugin_enabled; use codex_config::types::PluginConfig; use codex_config::version_for_toml; use codex_core_skills::SkillMetadata; +use codex_hooks::plugin_hook_declarations; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_plugin::AppConnectorId; @@ -61,8 +63,10 @@ use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginId; use codex_plugin::PluginIdError; use codex_plugin::prompt_safe_plugin_description; +use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::Product; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_plugins::PluginSkillRoot; use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; @@ -218,19 +222,28 @@ pub struct PluginReadOutcome { pub struct PluginDetail { pub id: String, pub name: String, + pub local_version: Option, pub description: Option, pub source: MarketplacePluginSource, pub policy: MarketplacePluginPolicy, pub interface: Option, + pub keywords: Vec, pub installed: bool, pub enabled: bool, pub skills: Vec, pub disabled_skill_paths: HashSet, + pub hooks: Vec, pub apps: Vec, pub mcp_server_names: Vec, pub details_unavailable_reason: Option, } +#[derive(Debug, Clone, PartialEq)] +pub struct PluginHookSummary { + pub key: String, + pub event_name: HookEventName, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PluginDetailsUnavailableReason { InstallRequiredForRemoteSource, @@ -248,9 +261,11 @@ pub struct ConfiguredMarketplace { pub struct ConfiguredMarketplacePlugin { pub id: String, pub name: String, + pub local_version: Option, pub source: MarketplacePluginSource, pub policy: MarketplacePluginPolicy, pub interface: Option, + pub keywords: Vec, pub installed: bool, pub enabled: bool, } @@ -540,10 +555,10 @@ impl PluginsManager { &self, config_layer_stack: &ConfigLayerStack, config: &PluginsConfigInput, - ) -> Vec { + ) -> Vec { self.plugins_for_layer_stack(config_layer_stack, config, config.plugin_hooks_enabled) .await - .effective_skill_roots() + .effective_plugin_skill_roots() } fn cached_enabled_outcome( @@ -1183,18 +1198,40 @@ impl PluginsManager { if !self.restriction_product_matches(plugin.policy.products.as_deref()) { return None; } + let installed = installed_plugins.contains(&plugin_key); + let enabled = enabled_plugins.contains(&plugin_key); + let mut interface = plugin.interface; + let mut local_version = plugin.local_version; + if installed + && matches!(&plugin.source, MarketplacePluginSource::Git { .. }) + && let Ok(plugin_id) = + PluginId::new(plugin.name.clone(), marketplace_name.clone()) + && let Some(plugin_root) = self.store.active_plugin_root(&plugin_id) + && let Some(manifest) = load_plugin_manifest(plugin_root.as_path()) + { + local_version = manifest.version.clone(); + let marketplace_category = interface + .as_ref() + .and_then(|interface| interface.category.clone()); + interface = plugin_interface_with_marketplace_category( + manifest.interface, + marketplace_category, + ); + } Some(ConfiguredMarketplacePlugin { // Enabled state is keyed by `@`, so duplicate // plugin entries from duplicate marketplace files intentionally // resolve to the first discovered source. - id: plugin_key.clone(), - installed: installed_plugins.contains(&plugin_key), - enabled: enabled_plugins.contains(&plugin_key), + id: plugin_key, + installed, + enabled, name: plugin.name, + local_version, source: plugin.source, policy: plugin.policy, - interface: plugin.interface, + keywords: plugin.keywords, + interface, }) }) .collect::>(); @@ -1241,9 +1278,18 @@ impl PluginsManager { ConfiguredMarketplacePlugin { id: plugin_key.clone(), name: plugin.plugin_id.plugin_name, + local_version: plugin + .manifest + .as_ref() + .and_then(|manifest| manifest.version.clone()), source: plugin.source, policy: plugin.policy, interface: plugin.interface, + keywords: plugin + .manifest + .as_ref() + .map(|manifest| manifest.keywords.clone()) + .unwrap_or_default(), installed: installed_plugins.contains(&plugin_key), enabled: enabled_plugins.contains(&plugin_key), }, @@ -1282,14 +1328,17 @@ impl PluginsManager { return Ok(PluginDetail { id: plugin_key, name: plugin.name, + local_version: None, description: Some(description), source: plugin.source, policy: plugin.policy, interface: plugin.interface, + keywords: plugin.keywords, installed: plugin.installed, enabled: plugin.enabled, skills: Vec::new(), disabled_skill_paths: HashSet::new(), + hooks: Vec::new(), apps: Vec::new(), mcp_server_names: Vec::new(), details_unavailable_reason: Some( @@ -1339,6 +1388,7 @@ impl PluginsManager { ); let resolved_skills = load_plugin_skills( &source_path, + &plugin_id, &manifest.paths, self.restriction_product, &codex_core_skills::config_rules::skill_config_rules_from_stack( @@ -1346,6 +1396,20 @@ impl PluginsManager { ), ) .await; + let hooks = if config.plugin_hooks_enabled { + let plugin_data_root = self.store.plugin_data_root(&plugin_id); + let (hook_sources, _hook_load_warnings) = + load_plugin_hooks(&source_path, &plugin_id, &plugin_data_root, &manifest.paths); + plugin_hook_declarations(&hook_sources) + .into_iter() + .map(|hook| PluginHookSummary { + key: hook.key, + event_name: hook.event_name, + }) + .collect() + } else { + Vec::new() + }; let apps = load_plugin_apps(source_path.as_path()).await; let mut mcp_server_names = load_plugin_mcp_servers(source_path.as_path()) .await @@ -1357,14 +1421,17 @@ impl PluginsManager { Ok(PluginDetail { id: plugin.id, name: plugin.name, + local_version: manifest.version.clone(), description, source: plugin.source, policy: plugin.policy, interface, + keywords: manifest.keywords, installed: plugin.installed, enabled: plugin.enabled, skills: resolved_skills.skills, disabled_skill_paths: resolved_skills.disabled_skill_paths, + hooks, apps, mcp_server_names, details_unavailable_reason: None, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 8abff7700b..d04852ea13 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -25,6 +25,7 @@ use codex_config::McpServerConfig; use codex_config::McpServerToolConfig; use codex_config::types::McpServerTransportConfig; use codex_login::CodexAuth; +use codex_protocol::protocol::HookEventName; use codex_protocol::protocol::Product; use codex_utils_absolute_path::test_support::PathBufExt; use pretty_assertions::assert_eq; @@ -1540,6 +1541,7 @@ enabled = false ConfiguredMarketplacePlugin { id: "enabled-plugin@debug".to_string(), name: "enabled-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo/enabled-plugin")) .unwrap(), @@ -1550,12 +1552,14 @@ enabled = false products: None, }, interface: None, + keywords: Vec::new(), installed: true, enabled: true, }, ConfiguredMarketplacePlugin { id: "disabled-plugin@debug".to_string(), name: "disabled-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo/disabled-plugin"),) .unwrap(), @@ -1566,6 +1570,7 @@ enabled = false products: None, }, interface: None, + keywords: Vec::new(), installed: true, enabled: false, }, @@ -1675,6 +1680,7 @@ plugins = true vec![ConfiguredMarketplacePlugin { id: "default-plugin@debug".to_string(), name: "default-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo/default-plugin")).unwrap(), }, @@ -1684,6 +1690,7 @@ plugins = true products: None, }, interface: None, + keywords: Vec::new(), installed: false, enabled: false, }] @@ -1930,13 +1937,48 @@ async fn read_plugin_for_config_installed_git_source_reads_from_cache_without_cl &cached_plugin_root.join(".mcp.json"), r#"{"mcpServers":{"toolkit":{"command":"toolkit-mcp"}}}"#, ); + write_file( + &cached_plugin_root.join("hooks/hooks.json"), + r#"{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "echo startup" + } + ] + } + ], + "PreToolUse": [ + { + "hooks": [ + { + "type": "command", + "command": "echo first" + }, + { + "type": "command", + "command": "echo second" + } + ] + } + ] + } +}"#, + ); write_file( &tmp.path().join(CONFIG_TOML_FILE), r#"[features] plugins = true +plugin_hooks = true [plugins."toolkit@debug"] enabled = true + +[hooks.state."toolkit@debug:hooks/hooks.json:pre_tool_use:0:0"] +enabled = false "#, ); @@ -1975,6 +2017,23 @@ enabled = true outcome.plugin.apps, vec![AppConnectorId("connector_calendar".to_string())] ); + assert_eq!( + outcome.plugin.hooks, + vec![ + PluginHookSummary { + key: "toolkit@debug:hooks/hooks.json:pre_tool_use:0:0".to_string(), + event_name: HookEventName::PreToolUse, + }, + PluginHookSummary { + key: "toolkit@debug:hooks/hooks.json:pre_tool_use:0:1".to_string(), + event_name: HookEventName::PreToolUse, + }, + PluginHookSummary { + key: "toolkit@debug:hooks/hooks.json:session_start:0:0".to_string(), + event_name: HookEventName::SessionStart, + }, + ] + ); assert_eq!(outcome.plugin.mcp_server_names, vec!["toolkit".to_string()]); assert!( !tmp.path() @@ -1983,6 +2042,117 @@ enabled = true ); } +#[tokio::test] +async fn list_marketplaces_installed_git_source_reads_metadata_from_cache_without_cloning() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo"); + let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo) + .unwrap() + .to_string(); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + write_file( + &repo_root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "debug", + "plugins": [ + {{ + "name": "toolkit", + "source": {{ + "source": "git-subdir", + "url": "{missing_remote_repo_url}", + "path": "plugins/toolkit" + }}, + "category": "Developer Tools" + }} + ] +}}"# + ), + ); + let cached_plugin_root = tmp.path().join("plugins/cache/debug/toolkit/local"); + write_file( + &cached_plugin_root.join(".codex-plugin/plugin.json"), + r##"{ + "name": "toolkit", + "interface": { + "displayName": "Toolkit", + "shortDescription": "Search cached data", + "category": "Cached Category", + "brandColor": "#3B82F6", + "composerIcon": "./assets/icon.png", + "logo": "./assets/logo.png", + "screenshots": ["./assets/screenshot.png"] + } +}"##, + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."toolkit@debug"] +enabled = true +"#, + ); + + let config = load_config(tmp.path(), &repo_root).await; + let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) + .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) + .unwrap() + .marketplaces; + + let marketplace = marketplaces + .into_iter() + .find(|marketplace| marketplace.name == "debug") + .expect("debug marketplace should be listed"); + + assert_eq!( + marketplace.plugins, + vec![ConfiguredMarketplacePlugin { + id: "toolkit@debug".to_string(), + name: "toolkit".to_string(), + local_version: None, + source: MarketplacePluginSource::Git { + url: missing_remote_repo_url, + path: Some("plugins/toolkit".to_string()), + ref_name: None, + sha: None, + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: Some(PluginManifestInterface { + display_name: Some("Toolkit".to_string()), + short_description: Some("Search cached data".to_string()), + category: Some("Developer Tools".to_string()), + brand_color: Some("#3B82F6".to_string()), + composer_icon: Some( + AbsolutePathBuf::try_from(cached_plugin_root.join("assets/icon.png")).unwrap(), + ), + logo: Some( + AbsolutePathBuf::try_from(cached_plugin_root.join("assets/logo.png")).unwrap(), + ), + screenshots: vec![ + AbsolutePathBuf::try_from(cached_plugin_root.join("assets/screenshot.png")) + .unwrap(), + ], + ..Default::default() + }), + keywords: Vec::new(), + installed: true, + enabled: true, + }] + ); + assert!( + !tmp.path() + .join("plugins/.marketplace-plugin-source-staging") + .exists() + ); +} + #[tokio::test] async fn sync_plugins_from_remote_returns_default_when_feature_disabled() { let tmp = tempfile::tempdir().unwrap(); @@ -2059,6 +2229,7 @@ plugins = true plugins: vec![ConfiguredMarketplacePlugin { id: "linear@openai-curated".to_string(), name: "linear".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(curated_root.join("plugins/linear")).unwrap(), }, @@ -2068,6 +2239,7 @@ plugins = true products: None, }, interface: None, + keywords: Vec::new(), installed: false, enabled: false, }], @@ -2352,6 +2524,7 @@ enabled = false vec![ConfiguredMarketplacePlugin { id: "dup-plugin@debug".to_string(), name: "dup-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo-a/from-a")).unwrap(), }, @@ -2361,6 +2534,7 @@ enabled = false products: None, }, interface: None, + keywords: Vec::new(), installed: false, enabled: true, }] @@ -2381,6 +2555,7 @@ enabled = false vec![ConfiguredMarketplacePlugin { id: "b-only-plugin@debug".to_string(), name: "b-only-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo-b/from-b-only")).unwrap(), }, @@ -2390,6 +2565,7 @@ enabled = false products: None, }, interface: None, + keywords: Vec::new(), installed: false, enabled: false, }] @@ -2464,6 +2640,7 @@ enabled = true plugins: vec![ConfiguredMarketplacePlugin { id: "sample-plugin@debug".to_string(), name: "sample-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(tmp.path().join("repo/sample-plugin")).unwrap(), }, @@ -2473,6 +2650,7 @@ enabled = true products: None, }, interface: None, + keywords: Vec::new(), installed: false, enabled: true, }], diff --git a/codex-rs/core-plugins/src/manifest.rs b/codex-rs/core-plugins/src/manifest.rs index 12b738f537..6de7f820b8 100644 --- a/codex-rs/core-plugins/src/manifest.rs +++ b/codex-rs/core-plugins/src/manifest.rs @@ -18,6 +18,8 @@ struct RawPluginManifest { version: Option, #[serde(default)] description: Option, + #[serde(default)] + keywords: Vec, // Keep manifest paths as raw strings so we can validate the required `./...` syntax before // resolving them under the plugin root. #[serde(default)] @@ -37,6 +39,7 @@ pub struct PluginManifest { pub name: String, pub version: Option, pub description: Option, + pub keywords: Vec, pub paths: PluginManifestPaths, pub interface: Option, } @@ -143,6 +146,7 @@ pub fn load_plugin_manifest(plugin_root: &Path) -> Option { name: raw_name, version, description, + keywords, skills, mcp_servers, apps, @@ -232,6 +236,7 @@ pub fn load_plugin_manifest(plugin_root: &Path) -> Option { name, version, description, + keywords, paths: PluginManifestPaths { skills: resolve_manifest_path(plugin_root, "skills", skills.as_deref()), mcp_servers: resolve_manifest_path( @@ -568,6 +573,28 @@ mod tests { assert_eq!(manifest.version, Some("1.2.3-beta+7".to_string())); } + #[test] + fn plugin_manifest_reads_keywords() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir"); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{ + "name": "demo-plugin", + "keywords": ["api-key", "developer tools"] +}"#, + ) + .expect("write manifest"); + + let manifest = load_manifest(&plugin_root); + + assert_eq!( + manifest.keywords, + vec!["api-key".to_string(), "developer tools".to_string()] + ); + } + #[test] fn plugin_manifest_uses_alternate_discoverable_path() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index dc6d01adf9..15959a7b55 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -59,9 +59,11 @@ pub struct MarketplaceInterface { #[derive(Debug, Clone, PartialEq, Eq)] pub struct MarketplacePlugin { pub name: String, + pub local_version: Option, pub source: MarketplacePluginSource, pub policy: MarketplacePluginPolicy, pub interface: Option, + pub keywords: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -288,11 +290,22 @@ pub fn load_marketplace(path: &AbsolutePathBuf) -> Result return Err(err), }; + let local_version = plugin + .manifest + .as_ref() + .and_then(|manifest| manifest.version.clone()); + let keywords = plugin + .manifest + .map(|manifest| manifest.keywords) + .unwrap_or_default(); + plugins.push(MarketplacePlugin { name: plugin.plugin_id.plugin_name, + local_version, source: plugin.source, policy: plugin.policy, interface: plugin.interface, + keywords, }); } diff --git a/codex-rs/core-plugins/src/marketplace_tests.rs b/codex-rs/core-plugins/src/marketplace_tests.rs index 217e2d8273..e134157177 100644 --- a/codex-rs/core-plugins/src/marketplace_tests.rs +++ b/codex-rs/core-plugins/src/marketplace_tests.rs @@ -388,6 +388,7 @@ fn list_marketplaces_supports_alternate_manifest_layout() { interface: None, plugins: vec![MarketplacePlugin { name: "string-source-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("plugins/string-source-plugin")) .unwrap(), @@ -413,6 +414,7 @@ fn list_marketplaces_supports_alternate_manifest_layout() { logo: None, screenshots: Vec::new(), }), + keywords: Vec::new(), }], }] ); @@ -452,6 +454,7 @@ fn list_marketplaces_includes_plugins_without_discoverable_manifest() { interface: None, plugins: vec![MarketplacePlugin { name: "missing-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("plugins/missing-plugin"),) .unwrap(), @@ -462,6 +465,7 @@ fn list_marketplaces_includes_plugins_without_discoverable_manifest() { products: None, }, interface: None, + keywords: Vec::new(), }], }] ); @@ -593,6 +597,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { plugins: vec![ MarketplacePlugin { name: "shared-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(home_root.join("home-shared")).unwrap(), }, @@ -602,9 +607,11 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { products: None, }, interface: None, + keywords: Vec::new(), }, MarketplacePlugin { name: "home-only".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(home_root.join("home-only")).unwrap(), }, @@ -614,6 +621,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { products: None, }, interface: None, + keywords: Vec::new(), }, ], }, @@ -626,6 +634,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { plugins: vec![ MarketplacePlugin { name: "shared-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("repo-shared")).unwrap(), }, @@ -635,9 +644,11 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { products: None, }, interface: None, + keywords: Vec::new(), }, MarketplacePlugin { name: "repo-only".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("repo-only")).unwrap(), }, @@ -647,6 +658,7 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { products: None, }, interface: None, + keywords: Vec::new(), }, ], }, @@ -715,6 +727,7 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() { interface: None, plugins: vec![MarketplacePlugin { name: "local-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(home_root.join("home-plugin")).unwrap(), }, @@ -724,6 +737,7 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() { products: None, }, interface: None, + keywords: Vec::new(), }], }, Marketplace { @@ -732,6 +746,7 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() { interface: None, plugins: vec![MarketplacePlugin { name: "local-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap(), }, @@ -741,6 +756,7 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() { products: None, }, interface: None, + keywords: Vec::new(), }], }, ] @@ -805,6 +821,7 @@ fn list_marketplaces_dedupes_multiple_roots_in_same_repo() { interface: None, plugins: vec![MarketplacePlugin { name: "local-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("plugin")).unwrap(), }, @@ -814,6 +831,7 @@ fn list_marketplaces_dedupes_multiple_roots_in_same_repo() { products: None, }, interface: None, + keywords: Vec::new(), }], }] ); @@ -967,6 +985,7 @@ fn list_marketplaces_skips_plugins_with_invalid_names_but_keeps_marketplace() { interface: None, plugins: vec![MarketplacePlugin { name: "valid-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("valid-plugin")).unwrap(), }, @@ -976,6 +995,7 @@ fn list_marketplaces_skips_plugins_with_invalid_names_but_keeps_marketplace() { products: None, }, interface: None, + keywords: Vec::new(), }], }] ); @@ -1083,6 +1103,7 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() { vec![ MarketplacePlugin { name: "local-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Local { path: AbsolutePathBuf::try_from(repo_root.join("plugins/local-plugin")) .unwrap(), @@ -1093,9 +1114,11 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() { products: None, }, interface: None, + keywords: Vec::new(), }, MarketplacePlugin { name: "url-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Git { url: "https://github.com/example/plugin.git".to_string(), path: None, @@ -1108,9 +1131,11 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() { products: None, }, interface: None, + keywords: Vec::new(), }, MarketplacePlugin { name: "git-subdir-plugin".to_string(), + local_version: None, source: MarketplacePluginSource::Git { url: "https://github.com/owner/repo.git".to_string(), path: Some("plugins/example".to_string()), @@ -1123,6 +1148,7 @@ fn list_marketplaces_keeps_remote_and_local_plugin_sources() { products: None, }, interface: None, + keywords: Vec::new(), }, ] ); diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 72335c476e..47563b95b8 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -29,15 +29,33 @@ pub use remote_installed_plugin_sync::RemotePluginCacheMutationGuard; pub use remote_installed_plugin_sync::mark_remote_plugin_cache_mutation_in_flight; pub use remote_installed_plugin_sync::maybe_start_remote_installed_plugin_bundle_sync; pub use remote_installed_plugin_sync::sync_remote_installed_plugin_bundles_once; +pub use share::RemotePluginShareAccessPolicy; +pub use share::RemotePluginShareDiscoverability; +pub use share::RemotePluginSharePrincipal; +pub use share::RemotePluginSharePrincipalRole; +pub use share::RemotePluginSharePrincipalType; pub use share::RemotePluginShareSaveResult; +pub use share::RemotePluginShareTarget; +pub use share::RemotePluginShareTargetRole; +pub use share::RemotePluginShareUpdateDiscoverability; +pub use share::RemotePluginShareUpdateTargetsResult; pub use share::delete_remote_plugin_share; pub use share::list_remote_plugin_shares; +pub use share::load_plugin_share_remote_ids_by_local_path; pub use share::save_remote_plugin_share; +pub use share::update_remote_plugin_share_targets; pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global"; -pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "chatgpt-workspace"; +pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "workspace-directory"; +pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME: &str = + "workspace-shared-with-me-private"; +pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME: &str = + "workspace-shared-with-me-unlisted"; pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Plugins"; -pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Workspace Plugins"; +pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "Workspace Directory"; +pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME: &str = "Shared with me"; +pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME: &str = + "Shared with me (unlisted)"; const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30); const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200; @@ -56,6 +74,13 @@ pub struct RemoteMarketplace { pub plugins: Vec, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RemoteMarketplaceSource { + Global, + WorkspaceDirectory, + SharedWithMe, +} + #[derive(Debug, Clone, PartialEq)] pub struct RemoteInstalledPlugin { pub marketplace_name: String, @@ -67,19 +92,32 @@ pub struct RemoteInstalledPlugin { #[derive(Debug, Clone, PartialEq)] pub struct RemotePluginSummary { pub id: String, + pub remote_plugin_id: String, pub name: String, + pub share_context: Option, pub installed: bool, pub enabled: bool, pub install_policy: PluginInstallPolicy, pub auth_policy: PluginAuthPolicy, pub availability: PluginAvailability, pub interface: Option, + pub keywords: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemotePluginShareContext { + pub remote_plugin_id: String, + pub remote_version: Option, + pub discoverability: RemotePluginShareDiscoverability, + pub share_url: Option, + pub creator_account_user_id: Option, + pub creator_name: Option, + pub share_principals: Option>, } #[derive(Debug, Clone, PartialEq)] pub struct RemotePluginShareSummary { pub summary: RemotePluginSummary, - pub share_url: Option, pub local_plugin_path: Option, } @@ -229,10 +267,6 @@ enum RemotePluginScope { } impl RemotePluginScope { - fn all() -> [Self; 2] { - [Self::Global, Self::Workspace] - } - fn api_value(self) -> &'static str { match self { Self::Global => "GLOBAL", @@ -257,7 +291,9 @@ impl RemotePluginScope { fn from_marketplace_name(name: &str) -> Option { match name { REMOTE_GLOBAL_MARKETPLACE_NAME => Some(Self::Global), - REMOTE_WORKSPACE_MARKETPLACE_NAME => Some(Self::Workspace), + REMOTE_WORKSPACE_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME => Some(Self::Workspace), _ => None, } } @@ -321,6 +357,8 @@ struct RemotePluginReleaseResponse { bundle_download_url: Option, #[serde(default)] app_ids: Vec, + #[serde(default)] + keywords: Vec, interface: RemotePluginReleaseInterfaceResponse, #[serde(default)] skills: Vec, @@ -332,7 +370,15 @@ struct RemotePluginDirectoryItem { name: String, scope: RemotePluginScope, #[serde(default)] + discoverability: Option, + #[serde(default)] + creator_account_user_id: Option, + #[serde(default)] + creator_name: Option, + #[serde(default)] share_url: Option, + #[serde(default)] + share_principals: Option>, installation_policy: PluginInstallPolicy, authentication_policy: PluginAuthPolicy, #[serde(rename = "status", default)] @@ -340,6 +386,42 @@ struct RemotePluginDirectoryItem { release: RemotePluginReleaseResponse, } +fn remote_plugin_canonical_marketplace_name( + plugin: &RemotePluginDirectoryItem, +) -> Result<&'static str, RemotePluginCatalogError> { + match plugin.scope { + RemotePluginScope::Global => Ok(REMOTE_GLOBAL_MARKETPLACE_NAME), + RemotePluginScope::Workspace => match workspace_plugin_discoverability(plugin)? { + RemotePluginShareDiscoverability::Listed => Ok(REMOTE_WORKSPACE_MARKETPLACE_NAME), + RemotePluginShareDiscoverability::Unlisted => { + Ok(REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME) + } + RemotePluginShareDiscoverability::Private => { + Ok(REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME) + } + }, + } +} + +fn workspace_plugin_discoverability( + plugin: &RemotePluginDirectoryItem, +) -> Result { + plugin.discoverability.ok_or_else(|| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "workspace plugin `{}` did not include discoverability", + plugin.id + )) + }) +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginDirectorySharePrincipal { + principal_type: RemotePluginSharePrincipalType, + principal_id: String, + role: RemotePluginSharePrincipalRole, + name: String, +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RemotePluginInstalledItem { #[serde(flatten)] @@ -370,101 +452,158 @@ struct RemotePluginMutationResponse { pub async fn fetch_remote_marketplaces( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, + sources: &[RemoteMarketplaceSource], ) -> Result, RemotePluginCatalogError> { let auth = ensure_chatgpt_auth(auth)?; - let mut directory_by_scope = - BTreeMap::>::new(); - let mut installed_by_scope = - BTreeMap::>::new(); - - let global = async { - let scope = RemotePluginScope::Global; - let (directory_plugins, installed_plugins) = tokio::try_join!( - fetch_directory_plugins_for_scope(config, auth, scope), - fetch_installed_plugins_for_scope(config, auth, scope), - )?; - Ok::<_, RemotePluginCatalogError>((scope, directory_plugins, installed_plugins)) - }; - let workspace = async { - let scope = RemotePluginScope::Workspace; - let (directory_plugins, installed_plugins) = tokio::try_join!( - fetch_directory_plugins_for_scope(config, auth, scope), - fetch_installed_plugins_for_scope(config, auth, scope), - )?; - Ok::<_, RemotePluginCatalogError>((scope, directory_plugins, installed_plugins)) - }; - - let (global, workspace) = tokio::try_join!(global, workspace)?; - for (scope, directory_plugins, installed_plugins) in [global, workspace] { - if !directory_plugins.is_empty() { - directory_by_scope.insert( - scope, - directory_plugins - .into_iter() - .map(|plugin| (plugin.id.clone(), plugin)) - .collect(), - ); - } - if !installed_plugins.is_empty() { - installed_by_scope.insert( - scope, - installed_plugins - .into_iter() - .map(|plugin| (plugin.plugin.id.clone(), plugin)) - .collect(), - ); - } - } - let mut marketplaces = Vec::new(); - for scope in RemotePluginScope::all() { - let directory_plugins = directory_by_scope.get(&scope); - let installed_plugins = installed_by_scope.get(&scope); - let plugin_ids = directory_plugins - .into_iter() - .flat_map(|plugins| plugins.keys()) - .chain( - installed_plugins - .into_iter() - .flat_map(|plugins| plugins.keys()), - ) - .cloned() - .collect::>(); - if plugin_ids.is_empty() { - continue; - } + let needs_workspace_installed = sources.iter().any(|source| { + matches!( + source, + RemoteMarketplaceSource::WorkspaceDirectory | RemoteMarketplaceSource::SharedWithMe + ) + }); + let workspace_installed_plugins = if needs_workspace_installed { + Some(fetch_installed_plugins_for_scope(config, auth, RemotePluginScope::Workspace).await?) + } else { + None + }; - let mut plugins = plugin_ids - .into_iter() - .filter_map(|plugin_id| { - let directory_plugin = - directory_plugins.and_then(|plugins| plugins.get(&plugin_id)); - let installed_plugin = - installed_plugins.and_then(|plugins| plugins.get(&plugin_id)); - directory_plugin - .or_else(|| installed_plugin.map(|plugin| &plugin.plugin)) - .map(|plugin| build_remote_plugin_summary(plugin, installed_plugin)) - }) - .collect::>(); - plugins.sort_by(|left, right| { - remote_plugin_display_name(left) - .to_ascii_lowercase() - .cmp(&remote_plugin_display_name(right).to_ascii_lowercase()) - .then_with(|| { - remote_plugin_display_name(left).cmp(remote_plugin_display_name(right)) - }) - .then_with(|| left.id.cmp(&right.id)) - }); - marketplaces.push(RemoteMarketplace { - name: scope.marketplace_name().to_string(), - display_name: scope.marketplace_display_name().to_string(), - plugins, - }); + for source in sources { + match source { + RemoteMarketplaceSource::Global => { + let scope = RemotePluginScope::Global; + let (directory_plugins, installed_plugins) = tokio::try_join!( + fetch_directory_plugins_for_scope(config, auth, scope), + fetch_installed_plugins_for_scope(config, auth, scope), + )?; + if let Some(marketplace) = build_remote_marketplace( + scope.marketplace_name(), + scope.marketplace_display_name(), + directory_plugins, + installed_plugins, + /*include_installed_only*/ true, + )? { + marketplaces.push(marketplace); + } + } + RemoteMarketplaceSource::WorkspaceDirectory => { + let scope = RemotePluginScope::Workspace; + let directory_plugins = + fetch_directory_plugins_for_scope(config, auth, scope).await?; + if let Some(marketplace) = build_remote_marketplace( + scope.marketplace_name(), + scope.marketplace_display_name(), + directory_plugins, + workspace_installed_plugins.clone().unwrap_or_default(), + /*include_installed_only*/ false, + )? { + marketplaces.push(marketplace); + } + } + RemoteMarketplaceSource::SharedWithMe => { + let private_plugins = fetch_shared_workspace_plugins(config, auth) + .await? + .into_iter() + .filter_map(|plugin| match workspace_plugin_discoverability(&plugin) { + Ok(RemotePluginShareDiscoverability::Private) => Some(Ok(plugin)), + Ok(RemotePluginShareDiscoverability::Listed) + | Ok(RemotePluginShareDiscoverability::Unlisted) => None, + Err(err) => Some(Err(err)), + }) + .collect::, _>>()?; + if let Some(marketplace) = build_remote_marketplace( + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME, + private_plugins, + workspace_installed_plugins.clone().unwrap_or_default(), + /*include_installed_only*/ false, + )? { + marketplaces.push(marketplace); + } + + let unlisted_installed_plugins = workspace_installed_plugins + .clone() + .unwrap_or_default() + .into_iter() + .filter_map( + |plugin| match workspace_plugin_discoverability(&plugin.plugin) { + Ok(RemotePluginShareDiscoverability::Unlisted) => Some(Ok(plugin)), + Ok(RemotePluginShareDiscoverability::Listed) + | Ok(RemotePluginShareDiscoverability::Private) => None, + Err(err) => Some(Err(err)), + }, + ) + .collect::, _>>()?; + if let Some(marketplace) = build_remote_marketplace( + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME, + Vec::new(), + unlisted_installed_plugins, + /*include_installed_only*/ true, + )? { + marketplaces.push(marketplace); + } + } + } } Ok(marketplaces) } +fn build_remote_marketplace( + name: &str, + display_name: &str, + directory_plugins: Vec, + installed_plugins: Vec, + include_installed_only: bool, +) -> Result, RemotePluginCatalogError> { + let directory_plugins = directory_plugins + .into_iter() + .map(|plugin| (plugin.id.clone(), plugin)) + .collect::>(); + let installed_plugins = installed_plugins + .into_iter() + .map(|plugin| (plugin.plugin.id.clone(), plugin)) + .collect::>(); + let plugin_ids = directory_plugins + .keys() + .chain( + include_installed_only + .then_some(&installed_plugins) + .into_iter() + .flat_map(|plugins| plugins.keys()), + ) + .cloned() + .collect::>(); + if plugin_ids.is_empty() { + return Ok(None); + } + + let mut plugins = plugin_ids + .into_iter() + .filter_map(|plugin_id| { + let directory_plugin = directory_plugins.get(&plugin_id); + let installed_plugin = installed_plugins.get(&plugin_id); + directory_plugin + .or_else(|| installed_plugin.map(|plugin| &plugin.plugin)) + .map(|plugin| (plugin, installed_plugin)) + }) + .map(|(plugin, installed_plugin)| build_remote_plugin_summary(plugin, installed_plugin)) + .collect::, _>>()?; + plugins.sort_by(|left, right| { + remote_plugin_display_name(left) + .to_ascii_lowercase() + .cmp(&remote_plugin_display_name(right).to_ascii_lowercase()) + .then_with(|| remote_plugin_display_name(left).cmp(remote_plugin_display_name(right))) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(Some(RemoteMarketplace { + name: name.to_string(), + display_name: display_name.to_string(), + plugins, + })) +} + pub async fn fetch_remote_installed_plugins( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, @@ -484,12 +623,9 @@ pub async fn fetch_remote_installed_plugins( let (global, workspace) = tokio::try_join!(global, workspace)?; let mut installed_plugins = [global, workspace] .into_iter() - .flat_map(|(scope, plugins)| { - plugins - .into_iter() - .map(move |plugin| remote_installed_plugin_to_info(scope, &plugin)) - }) - .collect::>(); + .flat_map(|(_scope, plugins)| plugins) + .map(|plugin| remote_installed_plugin_to_info(&plugin)) + .collect::, _>>()?; installed_plugins.sort_by(|left, right| { left.marketplace_name .cmp(&right.marketplace_name) @@ -514,6 +650,19 @@ pub async fn fetch_remote_plugin_detail( .await } +pub async fn fetch_remote_plugin_share_context( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + plugin_id: &str, +) -> Result, RemotePluginCatalogError> { + let auth = ensure_chatgpt_auth(auth)?; + let plugin = fetch_plugin_detail( + config, auth, plugin_id, /*include_download_urls*/ false, + ) + .await?; + remote_plugin_share_context(&plugin) +} + pub async fn fetch_remote_plugin_detail_with_download_urls( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, @@ -576,7 +725,7 @@ async fn fetch_remote_plugin_detail_with_download_url_option( let auth = ensure_chatgpt_auth(auth)?; let plugin = fetch_plugin_detail(config, auth, plugin_id, include_download_urls).await?; let scope = plugin.scope; - let marketplace_name = scope.marketplace_name().to_string(); + let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string(); // Remote plugin IDs uniquely identify remote plugins, so the caller-provided // marketplace name is not validated here. The backend detail response is the // source of truth for the plugin's actual scope/marketplace. @@ -625,7 +774,7 @@ async fn build_remote_plugin_detail( Ok(RemotePluginDetail { marketplace_name, marketplace_display_name: scope.marketplace_display_name().to_string(), - summary: build_remote_plugin_summary(&plugin, installed_plugin.as_ref()), + summary: build_remote_plugin_summary(&plugin, installed_plugin.as_ref())?, description: non_empty_string(Some(&plugin.release.description)), release_version: plugin.release.version, bundle_download_url: plugin.release.bundle_download_url, @@ -677,7 +826,7 @@ pub async fn uninstall_remote_plugin( config, auth, plugin_id, /*include_download_urls*/ false, ) .await?; - let marketplace_name = plugin.scope.marketplace_name().to_string(); + let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string(); let plugin_name = plugin.name; let base_url = config.chatgpt_base_url.trim_end_matches('/'); @@ -761,33 +910,73 @@ fn remove_remote_plugin_cache( fn build_remote_plugin_summary( plugin: &RemotePluginDirectoryItem, installed_plugin: Option<&RemotePluginInstalledItem>, -) -> RemotePluginSummary { - RemotePluginSummary { - id: plugin.id.clone(), +) -> Result { + let marketplace_name = remote_plugin_canonical_marketplace_name(plugin)?; + let plugin_id = + PluginId::new(plugin.name.clone(), marketplace_name.to_string()).map_err(|err| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "invalid remote plugin config id for `{}` in `{marketplace_name}`: {err}", + plugin.name + )) + })?; + Ok(RemotePluginSummary { + id: plugin_id.as_key(), + remote_plugin_id: plugin.id.clone(), name: plugin.name.clone(), + share_context: remote_plugin_share_context(plugin)?, installed: installed_plugin.is_some(), enabled: installed_plugin.is_some_and(|plugin| plugin.enabled), install_policy: plugin.installation_policy, auth_policy: plugin.authentication_policy, availability: plugin.availability, interface: remote_plugin_interface_to_info(plugin), + keywords: plugin.release.keywords.clone(), + }) +} + +fn remote_plugin_share_context( + plugin: &RemotePluginDirectoryItem, +) -> Result, RemotePluginCatalogError> { + match plugin.scope { + RemotePluginScope::Global => Ok(None), + RemotePluginScope::Workspace => { + let discoverability = workspace_plugin_discoverability(plugin)?; + Ok(Some(RemotePluginShareContext { + remote_plugin_id: plugin.id.clone(), + remote_version: plugin.release.version.clone(), + discoverability, + share_url: plugin.share_url.clone(), + creator_account_user_id: plugin.creator_account_user_id.clone(), + creator_name: plugin.creator_name.clone(), + share_principals: plugin.share_principals.as_ref().map(|share_principals| { + share_principals + .iter() + .map(|principal| RemotePluginSharePrincipal { + principal_type: principal.principal_type, + principal_id: principal.principal_id.clone(), + role: principal.role, + name: principal.name.clone(), + }) + .collect() + }), + })) + } } } fn remote_installed_plugin_to_info( - scope: RemotePluginScope, installed_plugin: &RemotePluginInstalledItem, -) -> RemoteInstalledPlugin { +) -> Result { let plugin = &installed_plugin.plugin; // Remote per-skill disabled state (`disabled_skill_names`) is intentionally // not projected into skills/list yet; local skills.config remains the // supported source for skill enablement. - RemoteInstalledPlugin { - marketplace_name: scope.marketplace_name().to_string(), + Ok(RemoteInstalledPlugin { + marketplace_name: remote_plugin_canonical_marketplace_name(plugin)?.to_string(), id: plugin.id.clone(), name: plugin.name.clone(), enabled: installed_plugin.enabled, - } + }) } fn remote_plugin_interface_to_info(plugin: &RemotePluginDirectoryItem) -> Option { @@ -895,6 +1084,24 @@ async fn fetch_directory_plugins_for_scope( Ok(plugins) } +async fn fetch_shared_workspace_plugins( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, +) -> Result, RemotePluginCatalogError> { + let mut plugins = Vec::new(); + let mut page_token = None; + loop { + let response = + get_remote_shared_workspace_plugins_page(config, auth, page_token.as_deref()).await?; + plugins.extend(response.plugins); + let Some(next_page_token) = response.pagination.next_page_token else { + break; + }; + page_token = Some(next_page_token); + } + Ok(plugins) +} + async fn fetch_installed_plugins_for_scope( config: &RemotePluginServiceConfig, auth: &CodexAuth, @@ -950,6 +1157,22 @@ async fn get_remote_plugin_list_page( send_and_decode(request, &url).await } +async fn get_remote_shared_workspace_plugins_page( + config: &RemotePluginServiceConfig, + auth: &CodexAuth, + page_token: Option<&str>, +) -> Result { + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/ps/plugins/workspace/shared"); + let client = build_reqwest_client(); + let mut request = authenticated_request(client.get(&url), auth)?; + request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]); + if let Some(page_token) = page_token { + request = request.query(&[("pageToken", page_token)]); + } + send_and_decode(request, &url).await +} + async fn get_remote_plugin_installed_page( config: &RemotePluginServiceConfig, auth: &CodexAuth, diff --git a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs index 4e5caca294..c94eba428e 100644 --- a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs +++ b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs @@ -1,10 +1,13 @@ use super::REMOTE_GLOBAL_MARKETPLACE_NAME; use super::REMOTE_WORKSPACE_MARKETPLACE_NAME; +use super::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME; +use super::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME; use super::RemotePluginCatalogError; use super::RemotePluginScope; use super::RemotePluginServiceConfig; use super::ensure_chatgpt_auth; use super::fetch_installed_plugins_for_scope_with_download_url; +use super::remote_plugin_canonical_marketplace_name; use crate::store::PLUGINS_CACHE_DIR; use crate::store::PluginStore; use crate::store::PluginStoreError; @@ -150,14 +153,22 @@ pub async fn sync_remote_installed_plugin_bundles_once( REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(), BTreeSet::new(), ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), ]); let mut installed_plugin_ids = BTreeSet::new(); let mut failed_remote_plugin_ids = BTreeSet::new(); - for (scope, installed_plugins) in [global, workspace] { - let marketplace_name = scope.marketplace_name().to_string(); + for (_scope, installed_plugins) in [global, workspace] { for installed_plugin in installed_plugins { let plugin = installed_plugin.plugin; + let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string(); installed_plugin_names_by_marketplace .entry(marketplace_name.clone()) .or_default() @@ -292,6 +303,8 @@ fn remove_stale_remote_plugin_caches( for marketplace_name in [ REMOTE_GLOBAL_MARKETPLACE_NAME, REMOTE_WORKSPACE_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME, ] { let marketplace_root = codex_home.join(PLUGINS_CACHE_DIR).join(marketplace_name); if !marketplace_root.exists() { @@ -449,6 +462,14 @@ mod tests { REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(), BTreeSet::new(), ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), ]); let guard = mark_remote_plugin_cache_mutation_in_flight( @@ -487,4 +508,49 @@ mod tests { assert_eq!(removed, vec!["linear@chatgpt-global".to_string()]); assert!(!cached_manifest.exists()); } + + #[test] + fn stale_remote_plugin_cleanup_removes_private_shared_with_me_cache() { + let codex_home = tempfile::tempdir().expect("create codex home"); + let cached_manifest = codex_home + .path() + .join(PLUGINS_CACHE_DIR) + .join(REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME) + .join("private-plugin") + .join("1.2.3") + .join(".codex-plugin") + .join("plugin.json"); + std::fs::create_dir_all(cached_manifest.parent().expect("manifest parent")) + .expect("create cached plugin manifest parent"); + std::fs::write(&cached_manifest, r#"{"name":"private-plugin"}"#) + .expect("write cached plugin manifest"); + let installed_plugin_names_by_marketplace = + BTreeMap::>::from_iter([ + (REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), BTreeSet::new()), + ( + REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), + ]); + + let removed = remove_stale_remote_plugin_caches( + codex_home.path(), + &installed_plugin_names_by_marketplace, + ) + .expect("cleanup private shared-with-me cache"); + + assert_eq!( + removed, + vec!["private-plugin@workspace-shared-with-me-private".to_string()] + ); + assert!(!cached_manifest.exists()); + } } diff --git a/codex-rs/core-plugins/src/remote/share.rs b/codex-rs/core-plugins/src/remote/share.rs index 58df033cfb..6afeab74b1 100644 --- a/codex-rs/core-plugins/src/remote/share.rs +++ b/codex-rs/core-plugins/src/remote/share.rs @@ -26,6 +26,71 @@ pub struct RemotePluginShareSaveResult { pub share_url: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct RemotePluginShareAccessPolicy { + pub discoverability: Option, + pub share_targets: Option>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RemotePluginShareDiscoverability { + Listed, + Unlisted, + Private, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum RemotePluginShareUpdateDiscoverability { + Unlisted, + Private, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RemotePluginSharePrincipalType { + User, + Group, + Workspace, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RemotePluginShareTarget { + pub principal_type: RemotePluginSharePrincipalType, + pub principal_id: String, + pub role: RemotePluginShareTargetRole, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct RemotePluginSharePrincipal { + pub principal_type: RemotePluginSharePrincipalType, + pub principal_id: String, + pub role: RemotePluginSharePrincipalRole, + pub name: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RemotePluginShareTargetRole { + Reader, + Editor, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RemotePluginSharePrincipalRole { + Reader, + Editor, + Owner, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemotePluginShareUpdateTargetsResult { + pub principals: Vec, + pub discoverability: RemotePluginShareDiscoverability, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] struct RemoteWorkspacePluginUploadUrlRequest<'a> { filename: &'a str, @@ -46,6 +111,10 @@ struct RemoteWorkspacePluginUploadUrlResponse { struct RemoteWorkspacePluginCreateRequest { file_id: String, etag: String, + #[serde(skip_serializing_if = "Option::is_none")] + discoverability: Option, + #[serde(skip_serializing_if = "Option::is_none")] + share_targets: Option>, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] @@ -54,12 +123,25 @@ struct RemoteWorkspacePluginCreateResponse { share_url: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct RemotePluginShareUpdateTargetsRequest { + discoverability: RemotePluginShareUpdateDiscoverability, + targets: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +struct RemotePluginShareUpdateTargetsResponse { + principals: Vec, + discoverability: RemotePluginShareDiscoverability, +} + pub async fn save_remote_plugin_share( config: &RemotePluginServiceConfig, auth: Option<&CodexAuth>, codex_home: &Path, plugin_path: &AbsolutePathBuf, remote_plugin_id: Option<&str>, + access_policy: RemotePluginShareAccessPolicy, ) -> Result { let auth = ensure_chatgpt_auth(auth)?; let plugin_path_for_archive = plugin_path.as_path().to_path_buf(); @@ -82,6 +164,9 @@ pub async fn save_remote_plugin_share( .etag .ok_or(RemotePluginCatalogError::MissingUploadEtag)?; put_workspace_plugin_upload(&upload.upload_url, archive_bytes).await?; + let share_targets = access_policy.share_targets; + let share_targets = + ensure_unlisted_workspace_target(auth, access_policy.discoverability, share_targets)?; let response = finalize_workspace_plugin_upload( config, auth, @@ -89,6 +174,8 @@ pub async fn save_remote_plugin_share( RemoteWorkspacePluginCreateRequest { file_id: upload.file_id, etag, + discoverability: access_policy.discoverability, + share_targets, }, ) .await?; @@ -133,23 +220,54 @@ pub async fn list_remote_plugin_shares( .map(|plugin| (plugin.plugin.id.clone(), plugin)) .collect::>(); let local_plugin_paths = - local_paths::load_plugin_share_local_paths(codex_home).unwrap_or_else(|err| { - warn!("failed to load plugin share local path mapping: {err}"); - BTreeMap::new() - }); + local_paths::load_plugin_share_local_paths(codex_home).map_err(|err| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "failed to load plugin share local path mapping: {err}" + )) + })?; - Ok(created_plugins + created_plugins .into_iter() .map(|plugin| { - let summary = build_remote_plugin_summary(&plugin, installed_by_id.get(&plugin.id)); - let local_plugin_path = local_plugin_paths.get(&plugin.id).cloned(); - RemotePluginShareSummary { - summary, - share_url: plugin.share_url, - local_plugin_path, + let summary = build_remote_plugin_summary(&plugin, installed_by_id.get(&plugin.id))?; + if summary + .share_context + .as_ref() + .and_then(|context| context.share_principals.as_ref()) + .is_none() + { + return Err(RemotePluginCatalogError::UnexpectedResponse(format!( + "created workspace plugin `{}` did not include share_principals", + plugin.id + ))); } + let local_plugin_path = local_plugin_paths.get(&plugin.id).cloned(); + Ok(RemotePluginShareSummary { + summary, + local_plugin_path, + }) }) - .collect()) + .collect() +} + +pub fn load_plugin_share_remote_ids_by_local_path( + codex_home: &Path, +) -> io::Result> { + let local_paths = local_paths::load_plugin_share_local_paths(codex_home)?; + local_paths + .into_iter() + .map(|(remote_plugin_id, local_plugin_path)| { + if !is_valid_remote_plugin_id(&remote_plugin_id) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "invalid remote plugin id in share local path mapping: {remote_plugin_id}" + ), + )); + } + Ok((local_plugin_path, remote_plugin_id)) + }) + .collect() } pub async fn delete_remote_plugin_share( @@ -173,6 +291,68 @@ pub async fn delete_remote_plugin_share( Ok(()) } +pub async fn update_remote_plugin_share_targets( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + remote_plugin_id: &str, + targets: Vec, + discoverability: RemotePluginShareUpdateDiscoverability, +) -> Result { + let auth = ensure_chatgpt_auth(auth)?; + let target_discoverability = match discoverability { + RemotePluginShareUpdateDiscoverability::Unlisted => { + RemotePluginShareDiscoverability::Unlisted + } + RemotePluginShareUpdateDiscoverability::Private => { + RemotePluginShareDiscoverability::Private + } + }; + let targets = + ensure_unlisted_workspace_target(auth, Some(target_discoverability), Some(targets))? + .unwrap_or_default(); + let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let url = format!("{base_url}/ps/plugins/{remote_plugin_id}/shares"); + let client = build_reqwest_client(); + let request = authenticated_request(client.put(&url), auth)?.json( + &RemotePluginShareUpdateTargetsRequest { + discoverability, + targets, + }, + ); + let response: RemotePluginShareUpdateTargetsResponse = send_and_decode(request, &url).await?; + Ok(RemotePluginShareUpdateTargetsResult { + principals: response.principals, + discoverability: response.discoverability, + }) +} + +fn ensure_unlisted_workspace_target( + auth: &CodexAuth, + discoverability: Option, + targets: Option>, +) -> Result>, RemotePluginCatalogError> { + if discoverability != Some(RemotePluginShareDiscoverability::Unlisted) { + return Ok(targets); + } + let account_id = auth.get_account_id().ok_or_else(|| { + RemotePluginCatalogError::UnexpectedResponse( + "workspace plugin share requires an account id".to_string(), + ) + })?; + let mut targets = targets.unwrap_or_default(); + if !targets.iter().any(|target| { + target.principal_type == RemotePluginSharePrincipalType::Workspace + && target.principal_id == account_id + }) { + targets.push(RemotePluginShareTarget { + principal_type: RemotePluginSharePrincipalType::Workspace, + principal_id: account_id, + role: RemotePluginShareTargetRole::Reader, + }); + } + Ok(Some(targets)) +} + async fn fetch_created_workspace_plugins( config: &RemotePluginServiceConfig, auth: &CodexAuth, diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index efdecdbbbc..0b8f8f2fb5 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -93,9 +93,11 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value { "id": plugin_id, "name": "demo-plugin", "scope": "WORKSPACE", + "discoverability": "PRIVATE", "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "release": { + "version": "0.1.0", "display_name": "Demo Plugin", "description": "Demo plugin description", "interface": { @@ -107,15 +109,18 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value { }) } -fn remote_plugin_json_with_share_url( +fn remote_plugin_json_with_share_url_and_principals( plugin_id: &str, share_url: Option<&str>, + share_principals: serde_json::Value, ) -> serde_json::Value { let mut plugin = remote_plugin_json(plugin_id); let serde_json::Value::Object(fields) = &mut plugin else { unreachable!("plugin json should be an object"); }; + fields.insert("discoverability".to_string(), json!("PRIVATE")); fields.insert("share_url".to_string(), json!(share_url)); + fields.insert("share_principals".to_string(), share_principals); plugin } @@ -202,6 +207,19 @@ async fn save_remote_plugin_share_creates_workspace_plugin() { .and(body_json(json!({ "file_id": "file_123", "etag": "\"upload_etag_123\"", + "discoverability": "UNLISTED", + "share_targets": [ + { + "principal_type": "user", + "principal_id": "user-1", + "role": "reader", + }, + { + "principal_type": "workspace", + "principal_id": "account_id", + "role": "reader", + }, + ], }))) .respond_with(ResponseTemplate::new(201).set_body_json(json!({ "plugin_id": "plugins_123", @@ -217,6 +235,14 @@ async fn save_remote_plugin_share_creates_workspace_plugin() { codex_home.path(), &plugin_path, /*remote_plugin_id*/ None, + RemotePluginShareAccessPolicy { + discoverability: Some(RemotePluginShareDiscoverability::Unlisted), + share_targets: Some(vec![RemotePluginShareTarget { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + role: RemotePluginShareTargetRole::Reader, + }]), + }, ) .await .unwrap(); @@ -354,6 +380,7 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() { codex_home.path(), &plugin_path, Some("plugins_123"), + RemotePluginShareAccessPolicy::default(), ) .await .unwrap(); @@ -367,6 +394,100 @@ async fn save_remote_plugin_share_updates_existing_workspace_plugin() { ); } +#[tokio::test] +async fn update_remote_plugin_share_targets_updates_targets() { + let server = MockServer::start().await; + let config = test_config(&server); + let auth = test_auth(); + + Mock::given(method("PUT")) + .and(path("/backend-api/ps/plugins/plugins_123/shares")) + .and(header("authorization", "Bearer Access Token")) + .and(header("chatgpt-account-id", "account_id")) + .and(body_json(json!({ + "discoverability": "UNLISTED", + "targets": [ + { + "principal_type": "user", + "principal_id": "user-1", + "role": "editor", + }, + { + "principal_type": "group", + "principal_id": "group-1", + "role": "reader", + }, + { + "principal_type": "workspace", + "principal_id": "account_id", + "role": "reader", + }, + ], + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "principals": [ + { + "principal_type": "user", + "principal_id": "user-1", + "role": "editor", + "name": "Gavin", + }, + { + "principal_type": "group", + "principal_id": "group-1", + "role": "reader", + "name": "Engineering", + }, + ], + "discoverability": "UNLISTED", + }))) + .expect(1) + .mount(&server) + .await; + + let result = update_remote_plugin_share_targets( + &config, + Some(&auth), + "plugins_123", + vec![ + RemotePluginShareTarget { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + role: RemotePluginShareTargetRole::Editor, + }, + RemotePluginShareTarget { + principal_type: RemotePluginSharePrincipalType::Group, + principal_id: "group-1".to_string(), + role: RemotePluginShareTargetRole::Reader, + }, + ], + RemotePluginShareUpdateDiscoverability::Unlisted, + ) + .await + .unwrap(); + + assert_eq!( + result, + RemotePluginShareUpdateTargetsResult { + principals: vec![ + RemotePluginSharePrincipal { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + role: RemotePluginSharePrincipalRole::Editor, + name: "Gavin".to_string(), + }, + RemotePluginSharePrincipal { + principal_type: RemotePluginSharePrincipalType::Group, + principal_id: "group-1".to_string(), + role: RemotePluginSharePrincipalRole::Reader, + name: "Engineering".to_string(), + }, + ], + discoverability: RemotePluginShareDiscoverability::Unlisted, + } + ); +} + #[tokio::test] async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { let codex_home = TempDir::new().unwrap(); @@ -387,9 +508,23 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { )) .and(query_param_is_missing("pageToken")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "plugins": [remote_plugin_json_with_share_url( + "plugins": [remote_plugin_json_with_share_url_and_principals( "plugins_123", Some("https://chatgpt.example/plugins/share/share-key-1"), + json!([ + { + "principal_type": "user", + "principal_id": "user-owner", + "role": "owner", + "name": "Owner", + }, + { + "principal_type": "user", + "principal_id": "user-reader", + "role": "reader", + "name": "Reader", + }, + ]), )], "pagination": { "next_page_token": "page-2" @@ -408,7 +543,24 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { )) .and(query_param("pageToken", "page-2")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "plugins": [remote_plugin_json_with_share_url("plugins_456", /*share_url*/ None)], + "plugins": [remote_plugin_json_with_share_url_and_principals( + "plugins_456", + /*share_url*/ None, + json!([ + { + "principal_type": "user", + "principal_id": "user-owner", + "role": "owner", + "name": "Owner", + }, + { + "principal_type": "user", + "principal_id": "user-editor", + "role": "editor", + "name": "Editor", + }, + ]), + )], "pagination": empty_pagination_json(), }))) .expect(1) @@ -434,30 +586,78 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { vec![ RemotePluginShareSummary { summary: RemotePluginSummary { - id: "plugins_123".to_string(), + id: "demo-plugin@workspace-shared-with-me-private".to_string(), + remote_plugin_id: "plugins_123".to_string(), name: "demo-plugin".to_string(), + share_context: Some(RemotePluginShareContext { + remote_plugin_id: "plugins_123".to_string(), + remote_version: Some("0.1.0".to_string()), + discoverability: RemotePluginShareDiscoverability::Private, + share_url: Some( + "https://chatgpt.example/plugins/share/share-key-1".to_string(), + ), + creator_account_user_id: None, + creator_name: None, + share_principals: Some(vec![ + RemotePluginSharePrincipal { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-owner".to_string(), + role: RemotePluginSharePrincipalRole::Owner, + name: "Owner".to_string(), + }, + RemotePluginSharePrincipal { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-reader".to_string(), + role: RemotePluginSharePrincipalRole::Reader, + name: "Reader".to_string(), + }, + ]), + }), installed: false, enabled: false, install_policy: PluginInstallPolicy::Available, auth_policy: PluginAuthPolicy::OnUse, availability: PluginAvailability::Available, interface: Some(expected_plugin_interface()), + keywords: Vec::new(), }, - share_url: Some("https://chatgpt.example/plugins/share/share-key-1".to_string()), local_plugin_path: Some(local_plugin_path), }, RemotePluginShareSummary { summary: RemotePluginSummary { - id: "plugins_456".to_string(), + id: "demo-plugin@workspace-shared-with-me-private".to_string(), + remote_plugin_id: "plugins_456".to_string(), name: "demo-plugin".to_string(), + share_context: Some(RemotePluginShareContext { + remote_plugin_id: "plugins_456".to_string(), + remote_version: Some("0.1.0".to_string()), + discoverability: RemotePluginShareDiscoverability::Private, + share_url: None, + creator_account_user_id: None, + creator_name: None, + share_principals: Some(vec![ + RemotePluginSharePrincipal { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-owner".to_string(), + role: RemotePluginSharePrincipalRole::Owner, + name: "Owner".to_string(), + }, + RemotePluginSharePrincipal { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-editor".to_string(), + role: RemotePluginSharePrincipalRole::Editor, + name: "Editor".to_string(), + }, + ]), + }), installed: true, enabled: true, install_policy: PluginInstallPolicy::Available, auth_policy: PluginAuthPolicy::OnUse, availability: PluginAvailability::Available, interface: Some(expected_plugin_interface()), + keywords: Vec::new(), }, - share_url: None, local_plugin_path: None, } ] diff --git a/codex-rs/core-skills/src/injection.rs b/codex-rs/core-skills/src/injection.rs index ed06cc578e..df62f42e85 100644 --- a/codex-rs/core-skills/src/injection.rs +++ b/codex-rs/core-skills/src/injection.rs @@ -59,6 +59,7 @@ pub async fn build_skill_injections( skill_name: skill.name.clone(), skill_scope: skill.scope, skill_path: skill.path_to_skills_md.to_path_buf(), + plugin_id: skill.plugin_id.clone(), invocation_type: InvocationType::Explicit, }); result.items.push(SkillInjection { diff --git a/codex-rs/core-skills/src/injection_tests.rs b/codex-rs/core-skills/src/injection_tests.rs index 9627318653..78aa195895 100644 --- a/codex-rs/core-skills/src/injection_tests.rs +++ b/codex-rs/core-skills/src/injection_tests.rs @@ -16,6 +16,7 @@ fn make_skill(name: &str, path: &str) -> SkillMetadata { policy: None, path_to_skills_md: test_path_buf(path).abs(), scope: codex_protocol::protocol::SkillScope::User, + plugin_id: None, } } diff --git a/codex-rs/core-skills/src/invocation_utils_tests.rs b/codex-rs/core-skills/src/invocation_utils_tests.rs index ab3a3e8dc0..f6e3883c16 100644 --- a/codex-rs/core-skills/src/invocation_utils_tests.rs +++ b/codex-rs/core-skills/src/invocation_utils_tests.rs @@ -21,6 +21,7 @@ fn test_skill_metadata(skill_doc_path: AbsolutePathBuf) -> SkillMetadata { policy: None, path_to_skills_md: skill_doc_path, scope: codex_protocol::protocol::SkillScope::User, + plugin_id: None, } } diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index d7a69e8a25..2473f7108c 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -19,6 +19,7 @@ use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; +use codex_utils_plugins::PluginSkillRoot; use codex_utils_plugins::plugin_namespace_for_skill_path; use dirs::home_dir; use serde::Deserialize; @@ -152,6 +153,7 @@ pub struct SkillRoot { pub path: AbsolutePathBuf, pub scope: SkillScope, pub file_system: Arc, + pub plugin_id: Option, } pub async fn load_skills_from_roots(roots: I) -> SkillLoadOutcome @@ -167,7 +169,14 @@ where let root_path = canonicalize_for_skill_identity(&root.path); let fs = root.file_system; let skills_before_root = outcome.skills.len(); - discover_skills_under_root(fs.as_ref(), &root_path, root.scope, &mut outcome).await; + discover_skills_under_root( + fs.as_ref(), + &root_path, + root.scope, + root.plugin_id.as_deref(), + &mut outcome, + ) + .await; for skill in &outcome.skills[skills_before_root..] { if !skill_roots.contains(&root_path) { skill_roots.push(root_path.clone()); @@ -222,7 +231,7 @@ pub(crate) async fn skill_roots( fs: Option>, config_layer_stack: &ConfigLayerStack, cwd: &AbsolutePathBuf, - plugin_skill_roots: Vec, + plugin_skill_roots: Vec, ) -> Vec { let home_dir = home_dir().and_then(|path| AbsolutePathBuf::from_absolute_path_checked(path).ok()); @@ -241,13 +250,14 @@ async fn skill_roots_with_home_dir( config_layer_stack: &ConfigLayerStack, cwd: &AbsolutePathBuf, home_dir: Option<&AbsolutePathBuf>, - plugin_skill_roots: Vec, + plugin_skill_roots: Vec, ) -> Vec { let mut roots = skill_roots_from_layer_stack_inner(config_layer_stack, home_dir, fs.clone()); - roots.extend(plugin_skill_roots.into_iter().map(|path| SkillRoot { - path, + roots.extend(plugin_skill_roots.into_iter().map(|root| SkillRoot { + path: root.path, scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some(root.plugin_id), })); roots.extend(repo_agents_skill_roots(fs, config_layer_stack, cwd).await); dedupe_skill_roots_by_path(&mut roots); @@ -276,6 +286,7 @@ fn skill_roots_from_layer_stack_inner( path: config_folder.join(SKILLS_DIR_NAME), scope: SkillScope::Repo, file_system: Arc::clone(repo_fs), + plugin_id: None, }); } } @@ -286,6 +297,7 @@ fn skill_roots_from_layer_stack_inner( path: config_folder.join(SKILLS_DIR_NAME), scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, }); // `$HOME/.agents/skills` (user-installed skills). @@ -294,6 +306,7 @@ fn skill_roots_from_layer_stack_inner( path: home_dir.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME), scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, }); } @@ -303,6 +316,7 @@ fn skill_roots_from_layer_stack_inner( path: system_cache_root_dir(&config_folder), scope: SkillScope::System, file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, }); } ConfigLayerSource::System { .. } => { @@ -312,6 +326,7 @@ fn skill_roots_from_layer_stack_inner( path: config_folder.join(SKILLS_DIR_NAME), scope: SkillScope::Admin, file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, }); } ConfigLayerSource::Mdm { .. } @@ -343,6 +358,7 @@ async fn repo_agents_skill_roots( path: agents_skills, scope: SkillScope::Repo, file_system: Arc::clone(&fs), + plugin_id: None, }), Ok(_) => {} Err(err) if err.kind() == io::ErrorKind::NotFound => {} @@ -441,6 +457,7 @@ async fn discover_skills_under_root( fs: &dyn ExecutorFileSystem, root: &AbsolutePathBuf, scope: SkillScope, + plugin_id: Option<&str>, outcome: &mut SkillLoadOutcome, ) { let root = canonicalize_for_skill_identity(root); @@ -553,7 +570,7 @@ async fn discover_skills_under_root( } if metadata.is_file && file_name == SKILLS_FILENAME { - match parse_skill_file(fs, &path, scope).await { + match parse_skill_file(fs, &path, scope, plugin_id).await { Ok(skill) => { outcome.skills.push(skill); } @@ -583,6 +600,7 @@ async fn parse_skill_file( fs: &dyn ExecutorFileSystem, path: &AbsolutePathBuf, scope: SkillScope, + plugin_id: Option<&str>, ) -> Result { let contents = fs .read_file_text(path, /*sandbox*/ None) @@ -639,6 +657,7 @@ async fn parse_skill_file( policy, path_to_skills_md: resolved_path, scope, + plugin_id: plugin_id.map(str::to_string), }) } diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index a12f09f80f..c80585871e 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -322,6 +322,7 @@ async fn loads_skills_from_home_agents_dir_for_user_scope() -> anyhow::Result<() policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, + plugin_id: None, }] ); @@ -472,6 +473,7 @@ async fn loads_skill_dependencies_metadata_from_yaml() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -527,6 +529,7 @@ interface: policy: None, path_to_skills_md: normalized(skill_path.as_path()), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -680,6 +683,7 @@ async fn accepts_icon_paths_under_assets_dir() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -720,6 +724,7 @@ async fn ignores_invalid_brand_color() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -773,6 +778,7 @@ async fn ignores_default_prompt_over_max_length() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -814,6 +820,7 @@ async fn drops_interface_when_icons_are_invalid() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -858,6 +865,7 @@ async fn loads_skills_via_symlinked_subdir_for_user_scope() { policy: None, path_to_skills_md: normalized(&shared_skill_path), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -917,6 +925,7 @@ async fn does_not_loop_on_symlink_cycle_for_user_scope() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -936,6 +945,7 @@ async fn loads_skills_via_symlinked_subdir_for_admin_scope() { path: admin_root.path().abs(), scope: SkillScope::Admin, file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, }]) .await; @@ -955,6 +965,7 @@ async fn loads_skills_via_symlinked_subdir_for_admin_scope() { policy: None, path_to_skills_md: normalized(&shared_skill_path), scope: SkillScope::Admin, + plugin_id: None, }] ); } @@ -994,6 +1005,7 @@ async fn loads_skills_via_symlinked_subdir_for_repo_scope() { policy: None, path_to_skills_md: normalized(&linked_skill_path), scope: SkillScope::Repo, + plugin_id: None, }] ); } @@ -1014,6 +1026,7 @@ async fn system_scope_ignores_symlinked_subdir() { path: system_root.abs(), scope: SkillScope::System, file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, }]) .await; assert!( @@ -1046,6 +1059,7 @@ async fn respects_max_scan_depth_for_user_scope() { path: skills_root.abs(), scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, }]) .await; @@ -1065,6 +1079,7 @@ async fn respects_max_scan_depth_for_user_scope() { policy: None, path_to_skills_md: normalized(&within_depth_path), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -1092,6 +1107,7 @@ async fn loads_valid_skill() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -1124,6 +1140,7 @@ async fn falls_back_to_directory_name_when_skill_name_is_missing() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -1148,6 +1165,7 @@ async fn namespaces_plugin_skills_using_plugin_name() { path: plugin_root.join("skills").abs(), scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), + plugin_id: Some("sample@test".to_string()), }]) .await; @@ -1167,6 +1185,7 @@ async fn namespaces_plugin_skills_using_plugin_name() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, + plugin_id: Some("sample@test".to_string()), }] ); } @@ -1198,6 +1217,7 @@ async fn loads_short_description_from_metadata() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::User, + plugin_id: None, }] ); } @@ -1310,6 +1330,7 @@ async fn loads_skills_from_repo_root() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, + plugin_id: None, }] ); } @@ -1345,6 +1366,7 @@ async fn loads_skills_from_agents_dir_without_codex_dir() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, + plugin_id: None, }] ); } @@ -1398,6 +1420,7 @@ async fn loads_skills_from_all_codex_dirs_under_project_root() { policy: None, path_to_skills_md: normalized(&nested_skill_path), scope: SkillScope::Repo, + plugin_id: None, }, SkillMetadata { name: "root-skill".to_string(), @@ -1408,6 +1431,7 @@ async fn loads_skills_from_all_codex_dirs_under_project_root() { policy: None, path_to_skills_md: normalized(&root_skill_path), scope: SkillScope::Repo, + plugin_id: None, }, ] ); @@ -1447,6 +1471,7 @@ async fn loads_skills_from_codex_dir_when_not_git_repo() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, + plugin_id: None, }] ); } @@ -1462,11 +1487,13 @@ async fn deduplicates_by_path_preferring_first_root() { path: root.path().abs(), scope: SkillScope::Repo, file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, }, SkillRoot { path: root.path().abs(), scope: SkillScope::User, file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, }, ]) .await; @@ -1487,6 +1514,7 @@ async fn deduplicates_by_path_preferring_first_root() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, + plugin_id: None, }] ); } @@ -1528,6 +1556,7 @@ async fn keeps_duplicate_names_from_repo_and_user() { policy: None, path_to_skills_md: normalized(&repo_skill_path), scope: SkillScope::Repo, + plugin_id: None, }, SkillMetadata { name: "dupe-skill".to_string(), @@ -1538,6 +1567,7 @@ async fn keeps_duplicate_names_from_repo_and_user() { policy: None, path_to_skills_md: normalized(&user_skill_path), scope: SkillScope::User, + plugin_id: None, }, ] ); @@ -1600,6 +1630,7 @@ async fn keeps_duplicate_names_from_nested_codex_dirs() { policy: None, path_to_skills_md: first_path, scope: SkillScope::Repo, + plugin_id: None, }, SkillMetadata { name: "dupe-skill".to_string(), @@ -1610,6 +1641,7 @@ async fn keeps_duplicate_names_from_nested_codex_dirs() { policy: None, path_to_skills_md: second_path, scope: SkillScope::Repo, + plugin_id: None, }, ] ); @@ -1681,6 +1713,7 @@ async fn loads_skills_when_cwd_is_file_in_repo() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::Repo, + plugin_id: None, }] ); } @@ -1739,6 +1772,7 @@ async fn loads_skills_from_system_cache_when_present() { policy: None, path_to_skills_md: normalized(&skill_path), scope: SkillScope::System, + plugin_id: None, }] ); } diff --git a/codex-rs/core-skills/src/manager.rs b/codex-rs/core-skills/src/manager.rs index b7b7a4b64d..db19322acf 100644 --- a/codex-rs/core-skills/src/manager.rs +++ b/codex-rs/core-skills/src/manager.rs @@ -8,6 +8,7 @@ use codex_exec_server::ExecutorFileSystem; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_plugins::PluginSkillRoot; use tracing::info; use tracing::warn; @@ -26,7 +27,7 @@ use codex_config::SkillsConfig; #[derive(Debug, Clone)] pub struct SkillsLoadInput { pub cwd: AbsolutePathBuf, - pub effective_skill_roots: Vec, + pub effective_skill_roots: Vec, pub config_layer_stack: ConfigLayerStack, pub bundled_skills_enabled: bool, } @@ -34,7 +35,7 @@ pub struct SkillsLoadInput { impl SkillsLoadInput { pub fn new( cwd: AbsolutePathBuf, - effective_skill_roots: Vec, + effective_skill_roots: Vec, config_layer_stack: ConfigLayerStack, bundled_skills_enabled: bool, ) -> Self { @@ -130,17 +131,6 @@ impl SkillsManager { input: &SkillsLoadInput, force_reload: bool, fs: Option>, - ) -> SkillLoadOutcome { - self.skills_for_cwd_with_extra_user_roots(input, force_reload, &[], fs) - .await - } - - pub async fn skills_for_cwd_with_extra_user_roots( - &self, - input: &SkillsLoadInput, - force_reload: bool, - extra_user_roots: &[AbsolutePathBuf], - fs: Option>, ) -> SkillLoadOutcome { let use_cwd_cache = fs.is_some(); if use_cwd_cache @@ -160,17 +150,6 @@ impl SkillsManager { if !bundled_skills_enabled_from_stack(&input.config_layer_stack) { roots.retain(|root| root.scope != SkillScope::System); } - if let Some(fs) = fs { - roots.extend( - normalize_extra_user_roots(extra_user_roots) - .into_iter() - .map(|path| SkillRoot { - path, - scope: SkillScope::User, - file_system: Arc::clone(&fs), - }), - ); - } let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack); let outcome = self.build_skill_outcome(roots, &skill_config_rules).await; if use_cwd_cache { @@ -239,7 +218,7 @@ impl SkillsManager { #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct ConfigSkillsCacheKey { - roots: Vec<(AbsolutePathBuf, u8)>, + roots: Vec<(AbsolutePathBuf, u8, Option)>, skill_config_rules: SkillConfigRules, } @@ -279,7 +258,7 @@ fn config_skills_cache_key( SkillScope::System => 2, SkillScope::Admin => 3, }; - (root.path.clone(), scope_rank) + (root.path.clone(), scope_rank, root.plugin_id.clone()) }) .collect(), skill_config_rules: skill_config_rules.clone(), @@ -298,16 +277,6 @@ fn finalize_skill_outcome( outcome } -fn normalize_extra_user_roots(extra_user_roots: &[AbsolutePathBuf]) -> Vec { - let mut normalized: Vec = extra_user_roots - .iter() - .map(|root| root.canonicalize().unwrap_or_else(|_| root.clone())) - .collect(); - normalized.sort_unstable(); - normalized.dedup(); - normalized -} - #[cfg(test)] #[path = "manager_tests.rs"] mod tests; diff --git a/codex-rs/core-skills/src/manager_tests.rs b/codex-rs/core-skills/src/manager_tests.rs index 73800a51d0..338da0c398 100644 --- a/codex-rs/core-skills/src/manager_tests.rs +++ b/codex-rs/core-skills/src/manager_tests.rs @@ -11,7 +11,7 @@ use codex_exec_server::LOCAL_FS; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::PathExt; -use codex_utils_absolute_path::test_support::test_path_buf; +use codex_utils_plugins::PluginSkillRoot; use pretty_assertions::assert_eq; use std::collections::HashSet; use std::fs; @@ -67,6 +67,7 @@ fn test_skill(name: &str, path: PathBuf) -> SkillMetadata { .canonicalize() .expect("skill path should canonicalize"), scope: SkillScope::User, + plugin_id: None, } } @@ -146,7 +147,14 @@ async fn skills_for_config_with_stack( ) -> SkillLoadOutcome { let skills_input = SkillsLoadInput::new( cwd.path().abs(), - effective_skill_roots.to_vec(), + effective_skill_roots + .iter() + .cloned() + .map(|path| PluginSkillRoot { + path, + plugin_id: "test-plugin@test".to_string(), + }) + .collect(), config_layer_stack.clone(), bundled_skills_enabled_from_stack(config_layer_stack), ); @@ -254,75 +262,13 @@ async fn skills_for_config_disables_plugin_skills_by_name() { } #[tokio::test] -async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() { +async fn skills_for_cwd_loads_repo_and_user_roots_with_local_fs() { let codex_home = tempfile::tempdir().expect("tempdir"); let cwd = tempfile::tempdir().expect("tempdir"); - let extra_root = tempfile::tempdir().expect("tempdir"); - let config_layer_stack = config_stack(&codex_home, ""); - let skills_manager = SkillsManager::new( - codex_home.path().abs(), - /*bundled_skills_enabled*/ true, - ); - let _ = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; - - write_user_skill(&extra_root, "x", "extra-skill", "from extra root"); - let extra_root_path = extra_root.path().abs(); - let base_input = SkillsLoadInput::new( - cwd.path().abs(), - Vec::new(), - config_layer_stack.clone(), - bundled_skills_enabled_from_stack(&config_layer_stack), - ); - let outcome_with_extra = skills_manager - .skills_for_cwd_with_extra_user_roots( - &base_input, - /*force_reload*/ true, - std::slice::from_ref(&extra_root_path), - Some(Arc::clone(&LOCAL_FS)), - ) - .await; - assert!( - outcome_with_extra - .skills - .iter() - .any(|skill| skill.name == "extra-skill") - ); - assert!( - outcome_with_extra - .skills - .iter() - .any(|skill| skill.scope == SkillScope::System) - ); - - // The cwd-only API returns the current cached entry for this cwd, even when that entry - // was produced with extra roots. - let base_input = SkillsLoadInput::new( - cwd.path().abs(), - Vec::new(), - config_layer_stack.clone(), - bundled_skills_enabled_from_stack(&config_layer_stack), - ); - let outcome_without_extra = skills_manager - .skills_for_cwd( - &base_input, - /*force_reload*/ false, - Some(Arc::clone(&LOCAL_FS)), - ) - .await; - assert_eq!(outcome_without_extra.skills, outcome_with_extra.skills); - assert_eq!(outcome_without_extra.errors, outcome_with_extra.errors); -} - -#[tokio::test] -async fn skills_for_cwd_loads_repo_user_and_extra_roots_with_local_fs() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let cwd = tempfile::tempdir().expect("tempdir"); - let extra_root = tempfile::tempdir().expect("tempdir"); let repo_dot_codex = cwd.path().join(".codex"); fs::create_dir_all(&repo_dot_codex).expect("create repo config dir"); write_user_skill(&codex_home, "user", "user-skill", "from local user root"); - write_user_skill(&extra_root, "extra", "extra-skill", "from extra root"); let repo_skill_dir = repo_dot_codex.join("skills/repo"); fs::create_dir_all(&repo_skill_dir).expect("create repo skill dir"); fs::write( @@ -357,10 +303,9 @@ async fn skills_for_cwd_loads_repo_user_and_extra_roots_with_local_fs() { ); let outcome = skills_manager - .skills_for_cwd_with_extra_user_roots( + .skills_for_cwd( &skills_input, /*force_reload*/ true, - &[extra_root.path().abs()], Some(Arc::clone(&LOCAL_FS)), ) .await; @@ -377,19 +322,16 @@ async fn skills_for_cwd_loads_repo_user_and_extra_roots_with_local_fs() { .collect::>(); assert!(loaded_names.contains("user-skill")); assert!(loaded_names.contains("repo-skill")); - assert!(loaded_names.contains("extra-skill")); } #[tokio::test] -async fn skills_for_cwd_without_fs_skips_repo_and_extra_roots() { +async fn skills_for_cwd_without_fs_skips_repo_roots() { let codex_home = tempfile::tempdir().expect("tempdir"); let cwd = tempfile::tempdir().expect("tempdir"); - let extra_root = tempfile::tempdir().expect("tempdir"); let repo_dot_codex = cwd.path().join(".codex"); fs::create_dir_all(&repo_dot_codex).expect("create repo config dir"); write_user_skill(&codex_home, "user", "user-skill", "from local user root"); - write_user_skill(&extra_root, "extra", "extra-skill", "from extra root"); let repo_skill_dir = repo_dot_codex.join("skills/repo"); fs::create_dir_all(&repo_skill_dir).expect("create repo skill dir"); fs::write( @@ -424,12 +366,7 @@ async fn skills_for_cwd_without_fs_skips_repo_and_extra_roots() { ); let outcome = skills_manager - .skills_for_cwd_with_extra_user_roots( - &skills_input, - /*force_reload*/ true, - &[extra_root.path().abs()], - /*fs*/ None, - ) + .skills_for_cwd(&skills_input, /*force_reload*/ true, /*fs*/ None) .await; assert!( @@ -444,7 +381,6 @@ async fn skills_for_cwd_without_fs_skips_repo_and_extra_roots() { .collect::>(); assert!(loaded_names.contains("user-skill")); assert!(!loaded_names.contains("repo-skill")); - assert!(!loaded_names.contains("extra-skill")); } #[tokio::test] @@ -490,22 +426,15 @@ async fn skills_for_config_excludes_bundled_skills_when_disabled_in_config() { } #[tokio::test] -async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() { +async fn skills_for_cwd_uses_cached_result_until_force_reload() { let codex_home = tempfile::tempdir().expect("tempdir"); let cwd = tempfile::tempdir().expect("tempdir"); - let extra_root_a = tempfile::tempdir().expect("tempdir"); - let extra_root_b = tempfile::tempdir().expect("tempdir"); let config_layer_stack = config_stack(&codex_home, ""); let skills_manager = SkillsManager::new( codex_home.path().abs(), /*bundled_skills_enabled*/ true, ); let _ = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]).await; - - write_user_skill(&extra_root_a, "x", "extra-skill-a", "from extra root a"); - write_user_skill(&extra_root_b, "x", "extra-skill-b", "from extra root b"); - - let extra_root_a_path = extra_root_a.path().abs(); let base_input = SkillsLoadInput::new( cwd.path().abs(), Vec::new(), @@ -513,32 +442,25 @@ async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() { bundled_skills_enabled_from_stack(&config_layer_stack), ); let outcome_a = skills_manager - .skills_for_cwd_with_extra_user_roots( - &base_input, - /*force_reload*/ true, - std::slice::from_ref(&extra_root_a_path), - Some(Arc::clone(&LOCAL_FS)), - ) - .await; - assert!( - outcome_a - .skills - .iter() - .any(|skill| skill.name == "extra-skill-a") - ); - assert!( - outcome_a - .skills - .iter() - .all(|skill| skill.name != "extra-skill-b") - ); - - let extra_root_b_path = extra_root_b.path().abs(); - let outcome_b = skills_manager - .skills_for_cwd_with_extra_user_roots( + .skills_for_cwd( + &base_input, + /*force_reload*/ false, + Some(Arc::clone(&LOCAL_FS)), + ) + .await; + assert!( + outcome_a + .skills + .iter() + .all(|skill| skill.name != "late-skill") + ); + + write_user_skill(&codex_home, "late", "late-skill", "added after cache"); + + let outcome_b = skills_manager + .skills_for_cwd( &base_input, /*force_reload*/ false, - std::slice::from_ref(&extra_root_b_path), Some(Arc::clone(&LOCAL_FS)), ) .await; @@ -546,20 +468,13 @@ async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() { outcome_b .skills .iter() - .any(|skill| skill.name == "extra-skill-a") - ); - assert!( - outcome_b - .skills - .iter() - .all(|skill| skill.name != "extra-skill-b") + .all(|skill| skill.name != "late-skill") ); let outcome_reloaded = skills_manager - .skills_for_cwd_with_extra_user_roots( + .skills_for_cwd( &base_input, /*force_reload*/ true, - std::slice::from_ref(&extra_root_b_path), Some(Arc::clone(&LOCAL_FS)), ) .await; @@ -567,25 +482,8 @@ async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() { outcome_reloaded .skills .iter() - .any(|skill| skill.name == "extra-skill-b") + .any(|skill| skill.name == "late-skill") ); - assert!( - outcome_reloaded - .skills - .iter() - .all(|skill| skill.name != "extra-skill-a") - ); -} - -#[test] -fn normalize_extra_user_roots_is_stable_for_equivalent_inputs() { - let a = test_path_buf("/tmp/a").abs(); - let b = test_path_buf("/tmp/b").abs(); - - let first = normalize_extra_user_roots(&[a.clone(), b.clone(), a.clone()]); - let second = normalize_extra_user_roots(&[b, a]); - - assert_eq!(first, second); } #[cfg_attr(windows, ignore)] diff --git a/codex-rs/core-skills/src/model.rs b/codex-rs/core-skills/src/model.rs index 0a72c24fe8..fc8e9f5917 100644 --- a/codex-rs/core-skills/src/model.rs +++ b/codex-rs/core-skills/src/model.rs @@ -19,6 +19,7 @@ pub struct SkillMetadata { /// Path to the SKILLS.md file that declares this skill. pub path_to_skills_md: AbsolutePathBuf, pub scope: SkillScope, + pub plugin_id: Option, } impl SkillMetadata { diff --git a/codex-rs/core-skills/src/render.rs b/codex-rs/core-skills/src/render.rs index 613ed9cbe5..28617fb6c4 100644 --- a/codex-rs/core-skills/src/render.rs +++ b/codex-rs/core-skills/src/render.rs @@ -922,6 +922,7 @@ mod tests { policy: None, path_to_skills_md: test_path_buf(&format!("/tmp/{name}/SKILL.md")).abs(), scope, + plugin_id: None, } } diff --git a/codex-rs/core/BUILD.bazel b/codex-rs/core/BUILD.bazel index dbca9ab63a..361e958e86 100644 --- a/codex-rs/core/BUILD.bazel +++ b/codex-rs/core/BUILD.bazel @@ -1,13 +1,5 @@ load("//:defs.bzl", "codex_rust_crate") -filegroup( - name = "model_availability_nux_fixtures", - srcs = [ - "tests/cli_responses_fixture.sse", - ], - visibility = ["//visibility:public"], -) - codex_rust_crate( name = "core", crate_name = "codex_core", @@ -52,9 +44,12 @@ codex_rust_crate( test_tags = ["no-sandbox"], unit_test_timeout = "long", extra_binaries = [ + "//codex-rs/bwrap:bwrap", "//codex-rs/linux-sandbox:codex-linux-sandbox", "//codex-rs/rmcp-client:test_stdio_server", "//codex-rs/rmcp-client:test_streamable_http_server", "//codex-rs/cli:codex", + "//codex-rs/windows-sandbox-rs:codex-command-runner", + "//codex-rs/windows-sandbox-rs:codex-windows-sandbox-setup", ], ) diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 44c6aacac5..28e8eaed93 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -5,7 +5,6 @@ name = "codex-core" version.workspace = true [lib] -doctest = false name = "codex_core" path = "src/lib.rs" @@ -36,6 +35,7 @@ codex-config = { workspace = true } codex-core-plugins = { workspace = true } codex-core-skills = { workspace = true } codex-exec-server = { workspace = true } +codex-extension-api = { workspace = true } codex-features = { workspace = true } codex-feedback = { workspace = true } codex-login = { workspace = true } @@ -60,6 +60,7 @@ codex-sandboxing = { workspace = true } codex-state = { workspace = true } codex-terminal-detection = { workspace = true } codex-thread-store = { workspace = true } +codex-tool-api = { workspace = true } codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cache = { workspace = true } @@ -69,7 +70,6 @@ codex-utils-output-truncation = { workspace = true } codex-utils-path = { workspace = true } codex-utils-plugins = { workspace = true } codex-utils-pty = { workspace = true } -codex-utils-readiness = { workspace = true } codex-utils-string = { workspace = true } codex-utils-stream-parser = { workspace = true } codex-utils-template = { workspace = true } @@ -77,7 +77,6 @@ codex-windows-sandbox = { package = "codex-windows-sandbox", path = "../windows- csv = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } -env-flags = { workspace = true } eventsource-stream = { workspace = true } futures = { workspace = true } http = { workspace = true } @@ -85,7 +84,6 @@ iana-time-zone = { workspace = true } image = { workspace = true, features = ["jpeg", "png", "webp"] } indexmap = { workspace = true } libc = { workspace = true } -notify = { workspace = true } once_cell = { workspace = true } rand = { workspace = true } regex-lite = { workspace = true } diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index be222a1673..3283ba2c3e 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -39,14 +39,14 @@ The Linux sandbox helper prefers the first `bwrap` found on `PATH` outside the current working directory whenever it is available. If `bwrap` is present but too old to support `--argv0`, the helper keeps using system bubblewrap and switches to a no-`--argv0` compatibility path for the inner re-exec. If -`bwrap` is missing, it falls back to the vendored bubblewrap path compiled into -the binary and Codex surfaces a startup warning through its normal notification -path instead of printing directly from the sandbox helper. Codex also surfaces -a startup warning when bubblewrap cannot create user namespaces. WSL2 uses the -normal Linux bubblewrap path. WSL1 is not supported for bubblewrap sandboxing -because it cannot create the required user namespaces, so Codex rejects -sandboxed shell commands that would enter the bubblewrap path before invoking -`bwrap`. +`bwrap` is missing, it falls back to the bundled `codex-resources/bwrap` +binary shipped with Codex and Codex surfaces a startup warning through its +normal notification path instead of printing directly from the sandbox helper. +Codex also surfaces a startup warning when bubblewrap cannot create user +namespaces. WSL2 uses the normal Linux bubblewrap path. WSL1 is not supported +for bubblewrap sandboxing because it cannot create the required user +namespaces, so Codex rejects sandboxed shell commands that would enter the +bubblewrap path before invoking `bwrap`. ### Windows diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index a30a3ed925..7601f01377 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -370,6 +370,9 @@ "apps_mcp_path_override": { "$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml" }, + "auth_elicitation": { + "type": "boolean" + }, "browser_use": { "type": "boolean" }, @@ -475,18 +478,27 @@ "memory_tool": { "type": "boolean" }, + "mentions_v2": { + "type": "boolean" + }, "multi_agent": { "type": "boolean" }, "multi_agent_v2": { "$ref": "#/definitions/FeatureToml_for_MultiAgentV2ConfigToml" }, + "network_proxy": { + "$ref": "#/definitions/FeatureToml_for_NetworkProxyConfigToml" + }, "personality": { "type": "boolean" }, "plugin_hooks": { "type": "boolean" }, + "plugin_sharing": { + "type": "boolean" + }, "plugins": { "type": "boolean" }, @@ -496,6 +508,9 @@ "realtime_conversation": { "type": "boolean" }, + "remote_compaction_v2": { + "type": "boolean" + }, "remote_control": { "type": "boolean" }, @@ -514,6 +529,9 @@ "request_rule": { "type": "boolean" }, + "responses_websocket_response_processed": { + "type": "boolean" + }, "responses_websockets": { "type": "boolean" }, @@ -607,6 +625,9 @@ "include_apps_instructions": { "type": "boolean" }, + "include_collaboration_mode_instructions": { + "type": "boolean" + }, "include_environment_context": { "type": "boolean" }, @@ -658,12 +679,8 @@ "$ref": "#/definitions/SandboxMode" }, "service_tier": { - "allOf": [ - { - "$ref": "#/definitions/ServiceTier" - } - ], - "description": "Optional explicit service tier preference for new turns (`fast` or `flex`)." + "description": "Optional explicit service tier request id for new turns (for example `priority` or `flex`; legacy `fast` also works).", + "type": "string" }, "tools": { "$ref": "#/definitions/ToolsToml" @@ -671,6 +688,15 @@ "tools_view_image": { "type": "boolean" }, + "tui": { + "allOf": [ + { + "$ref": "#/definitions/ProfileTui" + } + ], + "default": null, + "description": "TUI settings scoped to this profile." + }, "web_search": { "$ref": "#/definitions/WebSearchMode" }, @@ -785,6 +811,16 @@ } ] }, + "FeatureToml_for_NetworkProxyConfigToml": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/NetworkProxyConfigToml" + } + ] + }, "FeedbackConfigToml": { "additionalProperties": false, "properties": { @@ -939,6 +975,10 @@ "command": { "type": "string" }, + "commandWindows": { + "default": null, + "type": "string" + }, "statusMessage": { "default": null, "type": "string" @@ -996,6 +1036,9 @@ "properties": { "enabled": { "type": "boolean" + }, + "trusted_hash": { + "type": "string" } }, "type": "object" @@ -1009,6 +1052,13 @@ }, "type": "array" }, + "PostCompact": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, "PostToolUse": { "default": [], "items": { @@ -1016,6 +1066,13 @@ }, "type": "array" }, + "PreCompact": { + "default": [], + "items": { + "$ref": "#/definitions/MatcherGroup" + }, + "type": "array" + }, "PreToolUse": { "default": [], "items": { @@ -1460,6 +1517,75 @@ ], "type": "string" }, + "NetworkProxyConfigToml": { + "additionalProperties": false, + "properties": { + "allow_local_binding": { + "type": "boolean" + }, + "allow_upstream_proxy": { + "type": "boolean" + }, + "dangerously_allow_all_unix_sockets": { + "type": "boolean" + }, + "dangerously_allow_non_loopback_proxy": { + "type": "boolean" + }, + "domains": { + "additionalProperties": { + "$ref": "#/definitions/NetworkProxyDomainPermissionToml" + }, + "type": "object" + }, + "enable_socks5": { + "type": "boolean" + }, + "enable_socks5_udp": { + "type": "boolean" + }, + "enabled": { + "type": "boolean" + }, + "mode": { + "$ref": "#/definitions/NetworkProxyModeToml" + }, + "proxy_url": { + "type": "string" + }, + "socks_url": { + "type": "string" + }, + "unix_sockets": { + "additionalProperties": { + "$ref": "#/definitions/NetworkProxyUnixSocketPermissionToml" + }, + "type": "object" + } + }, + "type": "object" + }, + "NetworkProxyDomainPermissionToml": { + "enum": [ + "allow", + "deny" + ], + "type": "string" + }, + "NetworkProxyModeToml": { + "enum": [ + "limited", + "full" + ], + "type": "string" + }, + "NetworkProxyUnixSocketPermissionToml": { + "enum": [ + "allow", + "none" + ], + "type": "string" + }, "NetworkToml": { "additionalProperties": false, "properties": { @@ -1657,6 +1783,13 @@ ], "description": "Optional metrics exporter" }, + "span_attributes": { + "additionalProperties": { + "type": "string" + }, + "description": "Attributes to add to every exported trace span.", + "type": "object" + }, "trace_exporter": { "allOf": [ { @@ -1664,6 +1797,16 @@ } ], "description": "Optional trace exporter" + }, + "tracestate": { + "additionalProperties": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "description": "Semicolon-separated `key:value` fields to upsert into W3C tracestate members.", + "type": "object" } }, "type": "object" @@ -1870,6 +2013,22 @@ }, "type": "object" }, + "ProfileTui": { + "additionalProperties": false, + "description": "TUI settings supported inside a named profile.", + "properties": { + "session_picker_view": { + "allOf": [ + { + "$ref": "#/definitions/SessionPickerViewMode" + } + ], + "default": null, + "description": "Preferred layout for resume/fork session picker results." + } + }, + "type": "object" + }, "ProjectConfig": { "additionalProperties": false, "properties": { @@ -2150,10 +2309,11 @@ }, "type": "object" }, - "ServiceTier": { + "SessionPickerViewMode": { + "description": "Preferred layout for the resume/fork session picker.", "enum": [ - "fast", - "flex" + "comfortable", + "dense" ], "type": "string" }, @@ -2276,24 +2436,6 @@ "type" ], "type": "object" - }, - { - "properties": { - "endpoint": { - "type": "string" - }, - "type": { - "enum": [ - "remote" - ], - "type": "string" - } - }, - "required": [ - "endpoint", - "type" - ], - "type": "object" } ] }, @@ -2438,6 +2580,7 @@ "insert_newline": null, "kill_line_end": null, "kill_line_start": null, + "kill_whole_line": null, "move_down": null, "move_left": null, "move_line_end": null, @@ -2455,6 +2598,8 @@ "open_transcript": null, "queue": null, "submit": null, + "toggle_fast_mode": null, + "toggle_raw_output": null, "toggle_shortcuts": null, "toggle_vim_mode": null }, @@ -2553,6 +2698,34 @@ "default": true, "description": "Enable desktop notifications from the TUI. Defaults to `true`." }, + "pet": { + "default": null, + "description": "Pet id to preselect in the terminal pet picker.\n\nCustom pet ids resolve against CODEX_HOME/pets//pet.json.", + "type": "string" + }, + "pet_anchor": { + "allOf": [ + { + "$ref": "#/definitions/TuiPetAnchor" + } + ], + "default": "composer", + "description": "Where the terminal pet should anchor vertically.\n\nDefaults to `composer`, which follows the current TUI composer viewport." + }, + "raw_output_mode": { + "default": false, + "description": "Start the TUI in raw scrollback mode for copy-friendly transcript output. Defaults to `false`.", + "type": "boolean" + }, + "session_picker_view": { + "allOf": [ + { + "$ref": "#/definitions/SessionPickerViewMode" + } + ], + "default": null, + "description": "Preferred layout for resume/fork session picker results." + }, "show_tooltips": { "default": true, "description": "Show startup tooltips in the TUI welcome screen. Defaults to `true`.", @@ -2808,6 +2981,14 @@ ], "description": "Kill text from cursor to line start." }, + "kill_whole_line": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Kill the current line." + }, "move_down": { "allOf": [ { @@ -2935,6 +3116,22 @@ ], "description": "Submit the current composer draft." }, + "toggle_fast_mode": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Toggle Fast mode." + }, + "toggle_raw_output": { + "allOf": [ + { + "$ref": "#/definitions/KeybindingsSpec" + } + ], + "description": "Toggle raw scrollback mode for copy-friendly transcript selection." + }, "toggle_shortcuts": { "allOf": [ { @@ -3015,6 +3212,7 @@ "insert_newline": null, "kill_line_end": null, "kill_line_start": null, + "kill_whole_line": null, "move_down": null, "move_left": null, "move_line_end": null, @@ -3039,6 +3237,8 @@ "open_transcript": null, "queue": null, "submit": null, + "toggle_fast_mode": null, + "toggle_raw_output": null, "toggle_shortcuts": null, "toggle_vim_mode": null } @@ -3256,6 +3456,24 @@ }, "type": "object" }, + "TuiPetAnchor": { + "oneOf": [ + { + "description": "Anchor the pet to the bottom of the current TUI composer viewport.", + "enum": [ + "composer" + ], + "type": "string" + }, + { + "description": "Anchor the pet to the physical bottom of the terminal screen.", + "enum": [ + "screen-bottom" + ], + "type": "string" + } + ] + }, "TuiVimNormalKeymap": { "additionalProperties": false, "description": "Vim normal-mode keybindings for modal editing inside text areas.\n\nActions that use uppercase letters (like `A` for append-line-end) should be specified as `shift-a` in config; the runtime matcher handles cross-terminal shift-reporting differences automatically.", @@ -3746,7 +3964,7 @@ "description": "Preferred backend for storing CLI auth credentials. file (default): Use a file in the Codex home directory. keyring: Use an OS-specific keyring service. auto: Use the keyring if available, otherwise use a file." }, "commit_attribution": { - "description": "Optional commit attribution text for commit message co-author trailers.\n\nSet to an empty string to disable automatic commit attribution.", + "description": "Optional commit attribution text for commit message co-author trailers. This top-level setting only takes effect when `[features].codex_git_commit` is enabled.\n\nWhen enabled and unset, Codex uses `Codex `. Set to an empty string to disable automatic commit attribution.", "type": "string" }, "compact_prompt": { @@ -3809,10 +4027,6 @@ ], "description": "Experimental / do not use. Selects the thread store implementation." }, - "experimental_thread_store_endpoint": { - "description": "Experimental / do not use. When set, app-server uses a remote thread store at this endpoint instead of the local filesystem/SQLite store.", - "type": "string" - }, "experimental_use_freeform_apply_patch": { "type": "boolean" }, @@ -3836,6 +4050,9 @@ "apps_mcp_path_override": { "$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml" }, + "auth_elicitation": { + "type": "boolean" + }, "browser_use": { "type": "boolean" }, @@ -3941,18 +4158,27 @@ "memory_tool": { "type": "boolean" }, + "mentions_v2": { + "type": "boolean" + }, "multi_agent": { "type": "boolean" }, "multi_agent_v2": { "$ref": "#/definitions/FeatureToml_for_MultiAgentV2ConfigToml" }, + "network_proxy": { + "$ref": "#/definitions/FeatureToml_for_NetworkProxyConfigToml" + }, "personality": { "type": "boolean" }, "plugin_hooks": { "type": "boolean" }, + "plugin_sharing": { + "type": "boolean" + }, "plugins": { "type": "boolean" }, @@ -3962,6 +4188,9 @@ "realtime_conversation": { "type": "boolean" }, + "remote_compaction_v2": { + "type": "boolean" + }, "remote_control": { "type": "boolean" }, @@ -3980,6 +4209,9 @@ "request_rule": { "type": "boolean" }, + "responses_websocket_response_processed": { + "type": "boolean" + }, "responses_websockets": { "type": "boolean" }, @@ -4135,6 +4367,10 @@ "description": "Whether to inject the `` developer block.", "type": "boolean" }, + "include_collaboration_mode_instructions": { + "description": "Whether to inject the `` developer block.", + "type": "boolean" + }, "include_environment_context": { "description": "Whether to inject the `` user block.", "type": "boolean" @@ -4268,7 +4504,7 @@ }, "notify": { "default": null, - "description": "Deprecated optional external command to spawn for end-user notifications.", + "description": "Optional external command to spawn for end-user notifications.", "items": { "type": "string" }, @@ -4389,12 +4625,8 @@ "description": "Sandbox configuration to apply if `sandbox` is `WorkspaceWrite`." }, "service_tier": { - "allOf": [ - { - "$ref": "#/definitions/ServiceTier" - } - ], - "description": "Optional explicit service tier preference for new turns (`fast` or `flex`)." + "description": "Optional explicit service tier request id for new turns (for example `priority` or `flex`; legacy `fast` also works).", + "type": "string" }, "shell_environment_policy": { "allOf": [ diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 705d2d168f..d973f40e45 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -14,6 +14,7 @@ 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::SessionId; use codex_protocol::ThreadId; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; @@ -27,9 +28,9 @@ use codex_protocol::protocol::ResumedHistory; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::user_input::UserInput; -use codex_rollout::state_db; use codex_state::DirectionalThreadSpawnEdgeStatus; use codex_thread_store::ReadThreadParams; use serde::Serialize; @@ -115,6 +116,7 @@ fn keep_forked_rollout_item(item: &RolloutItem) -> bool { | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } + | ResponseItem::ContextCompaction { .. } | ResponseItem::Other, ) => false, // A forked child gets its own runtime config, including spawned-agent @@ -132,6 +134,9 @@ fn keep_forked_rollout_item(item: &RolloutItem) -> bool { /// which keeps the registry scoped to that root thread rather than the entire `ThreadManager`. #[derive(Clone, Default)] pub(crate) struct AgentControl { + /// ID shared by the whole agent control session. This means every sub-agents from a common + /// root share the same session ID. + session_id: SessionId, /// Weak handle back to the global thread registry/state. /// This is `Weak` to avoid reference cycles and shadow persistence of the form /// `ThreadManagerState -> CodexThread -> Session -> SessionServices -> ThreadManagerState`. @@ -148,6 +153,15 @@ impl AgentControl { } } + pub(crate) fn with_session_id(mut self, session_id: SessionId) -> Self { + self.session_id = session_id; + self + } + + pub(crate) fn session_id(&self) -> SessionId { + self.session_id + } + /// Spawn a new agent thread and submit the initial prompt. #[cfg(test)] pub(crate) async fn spawn_agent( @@ -219,31 +233,31 @@ impl AgentControl { // The same `AgentControl` is sent to spawn the thread. let new_thread = match (session_source, options.fork_mode.as_ref()) { (Some(session_source), Some(_)) => { - self.spawn_forked_thread( + Box::pin(self.spawn_forked_thread( &state, config, session_source, &options, inherited_shell_snapshot, inherited_exec_policy, - ) + )) .await? } (Some(session_source), None) => { - state - .spawn_new_thread_with_source( - config.clone(), - self.clone(), - session_source, - /*persist_extended_history*/ false, - /*metrics_service_name*/ None, - inherited_shell_snapshot, - inherited_exec_policy, - options.environments.clone(), - ) - .await? + Box::pin(state.spawn_new_thread_with_source( + config.clone(), + self.clone(), + session_source, + /*thread_source*/ Some(ThreadSource::Subagent), + /*persist_extended_history*/ false, + /*metrics_service_name*/ None, + inherited_shell_snapshot, + inherited_exec_policy, + options.environments.clone(), + )) + .await? } - (None, _) => state.spawn_new_thread(config.clone(), self.clone()).await?, + (None, _) => Box::pin(state.spawn_new_thread(config.clone(), self.clone())).await?, }; agent_metadata.agent_id = Some(new_thread.thread_id); reservation.commit(agent_metadata.clone()); @@ -356,14 +370,10 @@ impl AgentControl { 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?; + // `record_conversation_items` only queues persistence writes asynchronously. + // Flush before snapshotting store history for a fork. + parent_thread.ensure_rollout_materialized().await; + parent_thread.flush_rollout().await?; } let parent_history = state @@ -426,6 +436,7 @@ impl AgentControl { InitialHistory::Forked(forked_rollout_items), self.clone(), session_source, + /*thread_source*/ Some(ThreadSource::Subagent), /*persist_extended_history*/ false, inherited_shell_snapshot, inherited_exec_policy, @@ -525,6 +536,7 @@ impl AgentControl { let _ = config.features.disable(Feature::Collab); } let state = self.upgrade()?; + let state_db_ctx = state.state_db(); let mut reservation = self.state.reserve_spawn_slot(config.agent_max_threads)?; let (session_source, agent_metadata) = match session_source { SessionSource::SubAgent(SubAgentSource::ThreadSpawn { @@ -535,7 +547,7 @@ impl AgentControl { agent_nickname: _, }) => { let (resumed_agent_nickname, resumed_agent_role) = - if let Some(state_db_ctx) = state_db::get_state_db(&config).await { + if let Some(state_db_ctx) = state_db_ctx.as_ref() { match state_db_ctx.get_thread(thread_id).await { Ok(Some(metadata)) => (metadata.agent_nickname, metadata.agent_role), Ok(None) | Err(_) => (None, None), @@ -704,11 +716,13 @@ impl AgentControl { let result = if let Ok(thread) = state.get_thread(agent_id).await { thread.codex.session.ensure_rollout_materialized().await; thread.codex.session.flush_rollout().await?; - if matches!(thread.agent_status().await, AgentStatus::Shutdown) { + let result = if matches!(thread.agent_status().await, AgentStatus::Shutdown) { Ok(String::new()) } else { state.send_op(agent_id, Op::Shutdown {}).await - } + }; + thread.wait_until_terminated().await; + result } else { state.send_op(agent_id, Op::Shutdown {}).await }; diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 7ef2120d5c..b95aad4489 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::CodexThread; +use crate::StateDbHandle; use crate::ThreadManager; use crate::agent::agent_status_from_event; use crate::config::AgentRoleConfig; @@ -7,6 +8,7 @@ use crate::config::Config; use crate::config::ConfigBuilder; use crate::context::ContextualUserFragment; use crate::context::SubagentNotification; +use crate::init_state_db; use assert_matches::assert_matches; use codex_features::Feature; use codex_login::CodexAuth; @@ -84,6 +86,7 @@ fn spawn_agent_call(call_id: &str) -> ResponseItem { struct AgentControlHarness { _home: TempDir, config: Config, + state_db: Option, manager: ThreadManager, control: AgentControl, } @@ -91,16 +94,19 @@ struct AgentControlHarness { impl AgentControlHarness { async fn new() -> Self { let (home, config) = test_config().await; - let manager = ThreadManager::with_models_provider_and_home_for_tests( + let state_db = init_state_db(&config).await; + let manager = ThreadManager::with_models_provider_home_and_state_for_tests( CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + state_db.clone(), ); let control = manager.agent_control(); Self { _home: home, config, + state_db, manager, control, } @@ -1537,16 +1543,19 @@ async fn resume_thread_subagent_restores_stored_nickname_and_role() { .features .enable(Feature::Sqlite) .expect("test config should allow sqlite"); - let manager = ThreadManager::with_models_provider_and_home_for_tests( + let state_db = init_state_db(&config).await; + let manager = ThreadManager::with_models_provider_home_and_state_for_tests( CodexAuth::from_api_key("dummy"), config.model_provider.clone(), config.codex_home.to_path_buf(), std::sync::Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + state_db.clone(), ); let control = manager.agent_control(); let harness = AgentControlHarness { _home: home, config, + state_db, manager, control, }; @@ -1695,7 +1704,10 @@ async fn resume_agent_from_rollout_reads_archived_rollout_path() { .shutdown_live_agent(child_thread_id) .await .expect("child shutdown should succeed"); - let store = LocalThreadStore::new(LocalThreadStoreConfig::from_config(&harness.config)); + let store = LocalThreadStore::new( + LocalThreadStoreConfig::from_config(&harness.config), + harness.state_db.clone(), + ); store .archive_thread(ArchiveThreadParams { thread_id: child_thread_id, diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index 2550d58f82..1c99fb5950 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -657,7 +657,7 @@ enabled = false SkillsManager::new(home.path().abs(), /*bundled_skills_enabled*/ true); let plugins_input = config.plugins_config_input(); let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await; - let effective_skill_roots = plugin_outcome.effective_skill_roots(); + let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); let skills_input = skills_load_input_from_config(&config, effective_skill_roots); let outcome = skills_manager .skills_for_config( diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs index d5ebe4fe1f..38dc45ec1f 100644 --- a/codex-rs/core/src/apply_patch.rs +++ b/codex-rs/core/src/apply_patch.rs @@ -40,7 +40,7 @@ pub(crate) async fn apply_patch( turn_context.approval_policy.value(), &turn_context.permission_profile(), file_system_sandbox_policy, - &turn_context.cwd, + &action.cwd, turn_context.windows_sandbox_level, ) { SafetyCheck::AutoApprove { @@ -76,11 +76,10 @@ pub(crate) async fn apply_patch( pub(crate) fn convert_apply_patch_to_protocol( action: &ApplyPatchAction, ) -> HashMap { - let changes = action.changes(); - let mut result = HashMap::with_capacity(changes.len()); - for (path, change) in changes { + let mut result = HashMap::with_capacity(action.changes().len()); + for (path, change) in action.changes() { let protocol_change = match change { - ApplyPatchFileChange::Add { content } => FileChange::Add { + ApplyPatchFileChange::Add { content, .. } => FileChange::Add { content: content.clone(), }, ApplyPatchFileChange::Delete { content } => FileChange::Delete { @@ -95,7 +94,7 @@ pub(crate) fn convert_apply_patch_to_protocol( move_path: move_path.clone(), }, }; - result.insert(path.clone(), protocol_change); + result.insert(path.to_path_buf(), protocol_change); } result } diff --git a/codex-rs/core/src/arc_monitor.rs b/codex-rs/core/src/arc_monitor.rs index c7f12e1024..d1e679a631 100644 --- a/codex-rs/core/src/arc_monitor.rs +++ b/codex-rs/core/src/arc_monitor.rs @@ -384,6 +384,7 @@ fn build_arc_monitor_message_item( | ResponseItem::ToolSearchOutput { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } + | ResponseItem::ContextCompaction { .. } | ResponseItem::Other => None, } } diff --git a/codex-rs/core/src/arc_monitor_tests.rs b/codex-rs/core/src/arc_monitor_tests.rs index 4c2429cf5f..643042ec99 100644 --- a/codex-rs/core/src/arc_monitor_tests.rs +++ b/codex-rs/core/src/arc_monitor_tests.rs @@ -1,6 +1,5 @@ use std::env; use std::ffi::OsStr; -use std::path::PathBuf; use std::sync::Arc; use pretty_assertions::assert_eq; @@ -74,8 +73,7 @@ async fn build_arc_monitor_request_includes_relevant_history_and_null_policies() .record_into_history( &[ContextualUserFragment::into( crate::context::EnvironmentContext::new( - Some(PathBuf::from("/tmp")), - "zsh".to_string(), + Vec::new(), /*current_date*/ None, /*timezone*/ None, /*network*/ None, diff --git a/codex-rs/core/src/attestation.rs b/codex-rs/core/src/attestation.rs new file mode 100644 index 0000000000..e2ec309cb7 --- /dev/null +++ b/codex-rs/core/src/attestation.rs @@ -0,0 +1,26 @@ +use std::future::Future; +use std::pin::Pin; + +use codex_protocol::ThreadId; +use http::HeaderValue; + +pub(crate) const X_OAI_ATTESTATION_HEADER: &str = "x-oai-attestation"; + +pub type GenerateAttestationFuture<'a> = + Pin> + Send + 'a>>; + +/// Request context that host integrations can use when deciding whether to +/// generate an attestation header value. +#[derive(Clone, Copy, Debug)] +pub struct AttestationContext { + /// Thread whose upstream request is being prepared. + pub thread_id: ThreadId, +} + +/// Host integration boundary for just-in-time attestation header values. +/// +/// Implementations own the policy for when attestation should be attempted and +/// return the upstream `x-oai-attestation` header value when one should be sent. +pub trait AttestationProvider: std::fmt::Debug + Send + Sync { + fn header_for_request(&self, context: AttestationContext) -> GenerateAttestationFuture<'_>; +} diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index ba81b451a7..f604a63458 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -58,7 +58,7 @@ use codex_api::SseTelemetry; use codex_api::TransportError; use codex_api::WebsocketTelemetry; use codex_api::auth_header_telemetry; -use codex_api::build_conversation_headers; +use codex_api::build_session_headers; use codex_api::create_text_param_for_request; use codex_api::response_create_client_metadata; use codex_app_server_protocol::AuthMode; @@ -70,9 +70,9 @@ use codex_login::default_client::build_reqwest_client; use codex_otel::SessionTelemetry; use codex_otel::current_span_w3c_trace_context; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; -use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Verbosity as VerbosityConfig; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; @@ -100,14 +100,18 @@ use tokio::sync::oneshot::error::TryRecvError; use tokio_tungstenite::tungstenite::Error; use tokio_tungstenite::tungstenite::Message; use tokio_util::sync::CancellationToken; +use tracing::debug; use tracing::instrument; use tracing::trace; use tracing::warn; +use crate::attestation::AttestationContext; +use crate::attestation::AttestationProvider; +use crate::attestation::X_OAI_ATTESTATION_HEADER; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::client_common::ResponseStream; -use crate::flags::CODEX_RS_SSE_FIXTURE; +use crate::feedback_tags; use crate::util::emit_feedback_auth_recovery_tags; use codex_api::map_api_error; use codex_feedback::FeedbackRequestTags; @@ -137,6 +141,8 @@ pub const X_OPENAI_MEMGEN_REQUEST_HEADER: &str = "x-openai-memgen-request"; pub const X_OPENAI_SUBAGENT_HEADER: &str = "x-openai-subagent"; pub const X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER: &str = "x-responsesapi-include-timing-metrics"; +const X_CODEX_WS_STREAM_REQUEST_START_MS_CLIENT_METADATA_KEY: &str = + "x-codex-ws-stream-request-start-ms"; const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; const RESPONSES_ENDPOINT: &str = "/responses"; const RESPONSES_COMPACT_ENDPOINT: &str = "/responses/compact"; @@ -145,13 +151,20 @@ const MEMORIES_SUMMARIZE_ENDPOINT: &str = "/memories/trace_summarize"; pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration = Duration::from_millis(DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS); +pub(crate) struct CompactConversationRequestSettings { + pub(crate) effort: Option, + pub(crate) summary: ReasoningSummaryConfig, + pub(crate) service_tier: Option, +} + /// Session-scoped state shared by all [`ModelClient`] clones. /// /// This is intentionally kept minimal so `ModelClient` does not need to hold a full `Config`. Most /// configuration is per turn and is passed explicitly to streaming/unary methods. #[derive(Debug)] struct ModelClientState { - conversation_id: ThreadId, + session_id: SessionId, + thread_id: ThreadId, window_generation: AtomicU64, installation_id: String, provider: SharedModelProvider, @@ -161,6 +174,8 @@ struct ModelClientState { enable_request_compression: bool, include_timing_metrics: bool, beta_features_header: Option, + include_attestation: bool, + attestation_provider: Option>, disable_websockets: AtomicBool, cached_websocket_session: StdMutex, } @@ -189,7 +204,7 @@ impl RequestRouteTelemetry { /// A session-scoped client for model-provider API calls. /// /// This holds configuration and state that should be shared across turns within a Codex session -/// (auth, provider selection, conversation id, and transport fallback state). +/// (auth, provider selection, thread id, and transport fallback state). /// /// WebSocket fallback is session-scoped: once a turn activates the HTTP fallback, subsequent turns /// will also use HTTP for the remainder of the session. @@ -296,7 +311,8 @@ impl ModelClient { /// are passed to [`ModelClientSession::stream`] (and other turn-scoped methods) explicitly. pub fn new( auth_manager: Option>, - conversation_id: ThreadId, + session_id: SessionId, + thread_id: ThreadId, installation_id: String, provider_info: ModelProviderInfo, session_source: SessionSource, @@ -304,6 +320,7 @@ impl ModelClient { enable_request_compression: bool, include_timing_metrics: bool, beta_features_header: Option, + attestation_provider: Option>, ) -> Self { let model_provider = create_model_provider(provider_info, auth_manager); let codex_api_key_env_enabled = model_provider @@ -312,9 +329,11 @@ impl ModelClient { .is_some_and(|manager| manager.codex_api_key_env_enabled()); let auth_env_telemetry = collect_auth_env_telemetry(model_provider.info(), codex_api_key_env_enabled); + let include_attestation = model_provider.supports_attestation(); Self { state: Arc::new(ModelClientState { - conversation_id, + session_id, + thread_id, window_generation: AtomicU64::new(0), installation_id, provider: model_provider, @@ -324,6 +343,8 @@ impl ModelClient { enable_request_compression, include_timing_metrics, beta_features_header, + include_attestation, + attestation_provider, disable_websockets: AtomicBool::new(false), cached_websocket_session: StdMutex::new(WebsocketSession::default()), }), @@ -359,9 +380,9 @@ impl ModelClient { } fn current_window_id(&self) -> String { - let conversation_id = self.state.conversation_id; + let thread_id = self.state.thread_id; let window_generation = self.state.window_generation.load(Ordering::Relaxed); - format!("{conversation_id}:{window_generation}") + format!("{thread_id}:{window_generation}") } fn take_cached_websocket_session(&self) -> WebsocketSession { @@ -409,12 +430,11 @@ impl ModelClient { /// /// The model selection and telemetry context are passed explicitly to keep `ModelClient` /// session-scoped. - pub async fn compact_conversation_history( + pub(crate) async fn compact_conversation_history( &self, prompt: &Prompt, model_info: &ModelInfo, - effort: Option, - summary: ReasoningSummaryConfig, + settings: CompactConversationRequestSettings, session_telemetry: &SessionTelemetry, compaction_trace: &CompactionTraceContext, ) -> Result> { @@ -433,37 +453,35 @@ impl ModelClient { RequestRouteTelemetry::for_endpoint(RESPONSES_COMPACT_ENDPOINT), self.state.auth_env_telemetry.clone(), ); - let client = - ApiCompactClient::new(transport, client_setup.api_provider, client_setup.api_auth) - .with_telemetry(Some(request_telemetry)); - - let instructions = prompt.base_instructions.text.clone(); - let input = prompt.get_formatted_input(); - let tools = create_tools_json_for_responses_api(&prompt.tools)?; - let reasoning = Self::build_reasoning(model_info, effort, summary); - let verbosity = if model_info.support_verbosity { - self.state.model_verbosity.or(model_info.default_verbosity) - } else { - if self.state.model_verbosity.is_some() { - warn!( - "model_verbosity is set but ignored as the model does not support verbosity: {}", - model_info.slug - ); - } - None - }; - let text = create_text_param_for_request( - verbosity, - &prompt.output_schema, - prompt.output_schema_strict, - ); + let request = self.build_responses_request( + &client_setup.api_provider, + prompt, + model_info, + settings.effort, + settings.summary, + settings.service_tier, + )?; + let ResponsesApiRequest { + model, + instructions, + input, + tools, + parallel_tool_calls, + reasoning, + service_tier, + prompt_cache_key, + text, + .. + } = request; let payload = ApiCompactionInput { - model: &model_info.slug, + model: &model, input: &input, instructions: &instructions, tools, - parallel_tool_calls: prompt.parallel_tool_calls, + parallel_tool_calls, reasoning, + service_tier: service_tier.as_deref(), + prompt_cache_key: prompt_cache_key.as_deref(), text, }; @@ -471,10 +489,22 @@ impl ModelClient { if let Ok(header_value) = HeaderValue::from_str(&self.state.installation_id) { extra_headers.insert(X_CODEX_INSTALLATION_ID_HEADER, header_value); } + extra_headers.extend(build_responses_headers( + self.state.beta_features_header.as_deref(), + /*turn_state*/ None, + /*turn_metadata_header*/ None, + )); extra_headers.extend(self.build_responses_identity_headers()); - extra_headers.extend(build_conversation_headers(Some( - self.state.conversation_id.to_string(), - ))); + extra_headers.extend(build_session_headers( + Some(self.state.session_id.to_string()), + Some(self.state.thread_id.to_string()), + )); + if let Some(header_value) = self.generate_attestation_header_for().await { + extra_headers.insert(X_OAI_ATTESTATION_HEADER, header_value); + } + let client = + ApiCompactClient::new(transport, client_setup.api_provider, client_setup.api_auth) + .with_telemetry(Some(request_telemetry)); let trace_attempt = compaction_trace.start_attempt(&payload); let result = client .compact_input(&payload, extra_headers) @@ -488,11 +518,14 @@ impl ModelClient { &self, sdp: String, session_config: ApiRealtimeSessionConfig, - extra_headers: ApiHeaderMap, + mut extra_headers: ApiHeaderMap, ) -> Result { // Create the media call over HTTP first, then retain matching auth so realtime can attach // the server-side control WebSocket to the call id from that HTTP response. let client_setup = self.current_client_setup().await?; + if let Some(header_value) = self.generate_attestation_header_for().await { + extra_headers.insert(X_OAI_ATTESTATION_HEADER, header_value); + } let mut sideband_headers = extra_headers.clone(); sideband_headers.extend(sideband_websocket_auth_headers( client_setup.api_auth.as_ref(), @@ -623,6 +656,20 @@ impl ModelClient { client_metadata } + async fn generate_attestation_header_for(&self) -> Option { + if !self.state.include_attestation { + return None; + } + + self.state + .attestation_provider + .as_ref()? + .header_for_request(AttestationContext { + thread_id: self.state.thread_id, + }) + .await + } + /// Builds request telemetry for unary API calls (e.g., Compact endpoint). fn build_request_telemetry( session_telemetry: &SessionTelemetry, @@ -659,13 +706,71 @@ impl ModelClient { } } + fn build_responses_request( + &self, + provider: &codex_api::Provider, + prompt: &Prompt, + model_info: &ModelInfo, + effort: Option, + summary: ReasoningSummaryConfig, + service_tier: Option, + ) -> Result { + let instructions = &prompt.base_instructions.text; + let input = prompt.get_formatted_input(); + let tools = create_tools_json_for_responses_api(&prompt.tools)?; + let reasoning = Self::build_reasoning(model_info, effort, summary); + let include = if reasoning.is_some() { + vec!["reasoning.encrypted_content".to_string()] + } else { + Vec::new() + }; + let verbosity = if model_info.support_verbosity { + self.state.model_verbosity.or(model_info.default_verbosity) + } else { + if self.state.model_verbosity.is_some() { + warn!( + "model_verbosity is set but ignored as the model does not support verbosity: {}", + model_info.slug + ); + } + None + }; + let text = create_text_param_for_request( + verbosity, + &prompt.output_schema, + prompt.output_schema_strict, + ); + let prompt_cache_key = Some(self.state.thread_id.to_string()); + let service_tier = + service_tier.filter(|service_tier| model_info.supports_service_tier(service_tier)); + let request = ResponsesApiRequest { + model: model_info.slug.clone(), + instructions: instructions.clone(), + input, + tools, + tool_choice: "auto".to_string(), + parallel_tool_calls: prompt.parallel_tool_calls, + reasoning, + store: provider.is_azure_responses_endpoint(), + stream: true, + include, + service_tier, + prompt_cache_key, + text, + client_metadata: Some(HashMap::from([( + X_CODEX_INSTALLATION_ID_HEADER.to_string(), + self.state.installation_id.clone(), + )])), + }; + Ok(request) + } + /// Returns whether the Responses-over-WebSocket transport is active for this session. /// /// WebSocket use is controlled by provider capability and session-scoped fallback state. pub fn responses_websocket_enabled(&self) -> bool { if !self.state.provider.info().supports_websockets || self.state.disable_websockets.load(Ordering::Relaxed) - || (*CODEX_RS_SSE_FIXTURE).is_some() { return false; } @@ -703,7 +808,9 @@ impl ModelClient { auth_context: AuthRequestTelemetryContext, request_route_telemetry: RequestRouteTelemetry, ) -> std::result::Result { - let headers = self.build_websocket_headers(turn_state.as_ref(), turn_metadata_header); + let headers = self + .build_websocket_headers(turn_state.as_ref(), turn_metadata_header) + .await; let websocket_telemetry = ModelClientSession::build_websocket_telemetry( session_telemetry, auth_context, @@ -780,23 +887,27 @@ impl ModelClient { /// /// Callers should pass the current turn-state lock when available so sticky-routing state is /// replayed on reconnect within the same turn. - fn build_websocket_headers( + async fn build_websocket_headers( &self, turn_state: Option<&Arc>>, turn_metadata_header: Option<&str>, ) -> ApiHeaderMap { let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header); - let conversation_id = self.state.conversation_id.to_string(); + let session_id = self.state.session_id.to_string(); + let thread_id = self.state.thread_id.to_string(); let mut headers = build_responses_headers( self.state.beta_features_header.as_deref(), turn_state, turn_metadata_header.as_ref(), ); - if let Ok(header_value) = HeaderValue::from_str(&conversation_id) { + if let Ok(header_value) = HeaderValue::from_str(&thread_id) { headers.insert("x-client-request-id", header_value); } - headers.extend(build_conversation_headers(Some(conversation_id))); + headers.extend(build_session_headers(Some(session_id), Some(thread_id))); headers.extend(self.build_responses_identity_headers()); + if let Some(header_value) = self.generate_attestation_header_for().await { + headers.insert(X_OAI_ATTESTATION_HEADER, header_value); + } headers.insert( OPENAI_BETA_HEADER, HeaderValue::from_static(RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE), @@ -828,80 +939,16 @@ impl ModelClientSession { .set_connection_reused(/*connection_reused*/ false); } - fn build_responses_request( - &self, - provider: &codex_api::Provider, - prompt: &Prompt, - model_info: &ModelInfo, - effort: Option, - summary: ReasoningSummaryConfig, - service_tier: Option, - ) -> Result { - let instructions = &prompt.base_instructions.text; - let input = prompt.get_formatted_input(); - let tools = create_tools_json_for_responses_api(&prompt.tools)?; - let default_reasoning_effort = model_info.default_reasoning_level; - let reasoning = if model_info.supports_reasoning_summaries { - Some(Reasoning { - effort: effort.or(default_reasoning_effort), - summary: if summary == ReasoningSummaryConfig::None { - None - } else { - Some(summary) - }, - }) - } else { - None + pub(crate) async fn send_response_processed(&self, response_id: &str) { + let Some(connection) = self.websocket_session.connection.as_ref() else { + return; }; - let include = if reasoning.is_some() { - vec!["reasoning.encrypted_content".to_string()] - } else { - Vec::new() - }; - let verbosity = if model_info.support_verbosity { - self.client - .state - .model_verbosity - .or(model_info.default_verbosity) - } else { - if self.client.state.model_verbosity.is_some() { - warn!( - "model_verbosity is set but ignored as the model does not support verbosity: {}", - model_info.slug - ); - } - None - }; - let text = create_text_param_for_request( - verbosity, - &prompt.output_schema, - prompt.output_schema_strict, - ); - let prompt_cache_key = Some(self.client.state.conversation_id.to_string()); - let request = ResponsesApiRequest { - model: model_info.slug.clone(), - instructions: instructions.clone(), - input, - tools, - tool_choice: "auto".to_string(), - parallel_tool_calls: prompt.parallel_tool_calls, - reasoning, - store: provider.is_azure_responses_endpoint(), - stream: true, - include, - service_tier: match service_tier { - Some(ServiceTier::Fast) => Some("priority".to_string()), - Some(service_tier) => Some(service_tier.to_string()), - None => None, - }, - prompt_cache_key, - text, - client_metadata: Some(HashMap::from([( - X_CODEX_INSTALLATION_ID_HEADER.to_string(), - self.client.state.installation_id.clone(), - )])), - }; - Ok(request) + if let Err(err) = connection + .send_response_processed(response_id.to_string()) + .await + { + debug!("failed to send response.processed websocket request: {err}"); + } } #[allow(clippy::too_many_arguments)] @@ -909,15 +956,17 @@ impl ModelClientSession { /// /// Keeping option construction in one place ensures request-scoped headers are consistent /// regardless of transport choice. - fn build_responses_options( + async fn build_responses_options( &self, turn_metadata_header: Option<&str>, compression: Compression, ) -> ApiResponsesOptions { let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header); - let conversation_id = self.client.state.conversation_id.to_string(); + let session_id = self.client.state.session_id.to_string(); + let thread_id = self.client.state.thread_id.to_string(); ApiResponsesOptions { - conversation_id: Some(conversation_id), + session_id: Some(session_id), + thread_id: Some(thread_id), session_source: Some(self.client.state.session_source.clone()), extra_headers: { let mut headers = build_responses_headers( @@ -926,6 +975,9 @@ impl ModelClientSession { turn_metadata_header.as_ref(), ); headers.extend(self.client.build_responses_identity_headers()); + if let Some(header_value) = self.client.generate_attestation_header_for().await { + headers.insert(X_OAI_ATTESTATION_HEADER, header_value); + } headers }, compression, @@ -1140,8 +1192,7 @@ impl ModelClientSession { /// Streams a turn via the OpenAI Responses API. /// - /// Handles SSE fixtures, reasoning summaries, verbosity, and the - /// `text` controls used for output schemas. + /// Handles reasoning summaries, verbosity, and the `text` controls used for output schemas. #[allow(clippy::too_many_arguments)] #[instrument( name = "model_client.stream_responses_api", @@ -1163,25 +1214,10 @@ impl ModelClientSession { session_telemetry: &SessionTelemetry, effort: Option, summary: ReasoningSummaryConfig, - service_tier: Option, + service_tier: Option, turn_metadata_header: Option<&str>, inference_trace: &InferenceTraceContext, ) -> Result { - if let Some(path) = &*CODEX_RS_SSE_FIXTURE { - warn!(path, "Streaming from fixture"); - let stream = codex_api::stream_from_fixture( - path, - self.client.state.provider.info().stream_idle_timeout(), - ) - .map_err(map_api_error)?; - let (stream, _last_request_rx) = map_response_stream( - stream, - session_telemetry.clone(), - InferenceTraceAttempt::disabled(), - ); - return Ok(stream); - } - let auth_manager = self.client.state.provider.auth_manager(); let mut auth_recovery = auth_manager .as_ref() @@ -1202,17 +1238,20 @@ impl ModelClientSession { self.client.state.auth_env_telemetry.clone(), ); let compression = self.responses_request_compression(client_setup.auth.as_ref()); - let options = self.build_responses_options(turn_metadata_header, compression); + let mut options = self + .build_responses_options(turn_metadata_header, compression) + .await; - let request = self.build_responses_request( + let request = self.client.build_responses_request( &client_setup.api_provider, prompt, model_info, effort, summary, - service_tier, + service_tier.clone(), )?; let inference_trace_attempt = inference_trace.start_attempt(); + inference_trace_attempt.add_request_headers(&mut options.extra_headers); inference_trace_attempt.record_started(&request); let client = ApiResponsesClient::new( transport, @@ -1288,7 +1327,7 @@ impl ModelClientSession { session_telemetry: &SessionTelemetry, effort: Option, summary: ReasoningSummaryConfig, - service_tier: Option, + service_tier: Option, turn_metadata_header: Option<&str>, warmup: bool, request_trace: Option, @@ -1309,14 +1348,16 @@ impl ModelClientSession { ); let compression = self.responses_request_compression(client_setup.auth.as_ref()); - let options = self.build_responses_options(turn_metadata_header, compression); - let request = self.build_responses_request( + let options = self + .build_responses_options(turn_metadata_header, compression) + .await; + let request = self.client.build_responses_request( &client_setup.api_provider, prompt, model_info, effort, summary, - service_tier, + service_tier.clone(), )?; let mut ws_payload = ResponseCreateWsRequest { client_metadata: response_create_client_metadata( @@ -1365,7 +1406,7 @@ impl ModelClientSession { Err(err) => return Err(map_api_error(err)), } - let ws_request = self.prepare_websocket_request(ws_payload, &request); + let mut ws_request = self.prepare_websocket_request(ws_payload, &request); self.websocket_session.last_request = Some(request); let inference_trace_attempt = if warmup { // Prewarm sends `generate=false`; it is connection setup, not a @@ -1374,6 +1415,7 @@ impl ModelClientSession { } else { inference_trace.start_attempt() }; + stamp_ws_stream_request_start_ms(&mut ws_request); inference_trace_attempt.record_started(&ws_request); let websocket_connection = self.websocket_session.connection.as_ref().ok_or_else(|| { @@ -1448,7 +1490,7 @@ impl ModelClientSession { session_telemetry: &SessionTelemetry, effort: Option, summary: ReasoningSummaryConfig, - service_tier: Option, + service_tier: Option, turn_metadata_header: Option<&str>, ) -> Result<()> { if !self.client.responses_websocket_enabled() { @@ -1509,7 +1551,7 @@ impl ModelClientSession { session_telemetry: &SessionTelemetry, effort: Option, summary: ReasoningSummaryConfig, - service_tier: Option, + service_tier: Option, turn_metadata_header: Option<&str>, inference_trace: &InferenceTraceContext, ) -> Result { @@ -1525,7 +1567,7 @@ impl ModelClientSession { session_telemetry, effort, summary, - service_tier, + service_tier.clone(), turn_metadata_header, /*warmup*/ false, request_trace, @@ -1582,6 +1624,23 @@ fn parse_turn_metadata_header(turn_metadata_header: Option<&str>) -> Option = Vec::new(); let mut api_stream = api_stream; let upstream_request_id = upstream_request_id.as_deref(); + if let Some(upstream_request_id) = upstream_request_id { + feedback_tags!(last_model_request_id = upstream_request_id); + } loop { let event = tokio::select! { _ = consumer_dropped.cancelled() => { @@ -1734,6 +1796,7 @@ where token_usage, end_turn, }) => { + feedback_tags!(last_model_response_id = &response_id); if let Some(usage) = &token_usage { session_telemetry.sse_event_completed( usage.input_tokens, @@ -1782,6 +1845,9 @@ where extract_response_debug_context_from_api_error(&err); let upstream_request_id = upstream_request_id.or(response_debug_context.request_id.as_deref()); + if let Some(upstream_request_id) = upstream_request_id { + feedback_tags!(last_model_request_id = upstream_request_id); + } let mapped = map_api_error(err); inference_trace_attempt.record_failed( &mapped, diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index e56500ba5f..b9d9172c83 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -7,13 +7,21 @@ use super::X_CODEX_PARENT_THREAD_ID_HEADER; use super::X_CODEX_TURN_METADATA_HEADER; use super::X_CODEX_WINDOW_ID_HEADER; use super::X_OPENAI_SUBAGENT_HEADER; +use crate::AttestationContext; +use crate::AttestationProvider; +use crate::GenerateAttestationFuture; use codex_api::ApiError; use codex_api::ResponseEvent; use codex_app_server_protocol::AuthMode; +use codex_login::AuthManager; +use codex_login::CodexAuth; use codex_model_provider::BearerAuthProvider; +use codex_model_provider_info::CHATGPT_CODEX_BASE_URL; +use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::WireApi; use codex_model_provider_info::create_oss_provider_with_base_url; use codex_otel::SessionTelemetry; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -31,20 +39,34 @@ use codex_rollout_trace::replay_bundle; use futures::StreamExt; use pretty_assertions::assert_eq; use serde_json::json; +use std::collections::BTreeMap; use std::collections::VecDeque; use std::pin::Pin; use std::sync::Arc; +use std::sync::Mutex; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; use std::task::Context; use std::task::Poll; use std::time::Duration; use tempfile::TempDir; use tokio::sync::Notify; +use tracing::Event; +use tracing::Subscriber; +use tracing::field::Visit; +use tracing_subscriber::Layer; +use tracing_subscriber::layer::Context as LayerContext; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::registry::LookupSpan; +use tracing_subscriber::util::SubscriberInitExt; fn test_model_client(session_source: SessionSource) -> ModelClient { let provider = create_oss_provider_with_base_url("https://example.com/v1", WireApi::Responses); + let thread_id = ThreadId::new(); ModelClient::new( /*auth_manager*/ None, - ThreadId::new(), + thread_id.into(), + thread_id, /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), provider, session_source, @@ -52,6 +74,7 @@ fn test_model_client(session_source: SessionSource) -> ModelClient { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*attestation_provider*/ None, ) } @@ -100,6 +123,42 @@ fn test_session_telemetry() -> SessionTelemetry { ) } +#[derive(Default)] +struct TagCollectorVisitor { + tags: BTreeMap, +} + +impl Visit for TagCollectorVisitor { + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + self.tags + .insert(field.name().to_string(), value.to_string()); + } + + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + self.tags + .insert(field.name().to_string(), format!("{value:?}")); + } +} + +#[derive(Clone)] +struct TagCollectorLayer { + tags: Arc>>, +} + +impl Layer for TagCollectorLayer +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + fn on_event(&self, event: &Event<'_>, _ctx: LayerContext<'_, S>) { + if event.metadata().target() != "feedback_tags" { + return; + } + let mut visitor = TagCollectorVisitor::default(); + event.record(&mut visitor); + self.tags.lock().unwrap().extend(visitor.tags); + } +} + fn started_inference_attempt(temp: &TempDir) -> anyhow::Result { let writer = Arc::new(TraceWriter::create( temp.path(), @@ -224,7 +283,7 @@ fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() { client.advance_window_generation(); let client_metadata = client.build_ws_client_metadata(Some(r#"{"turn_id":"turn-123"}"#)); - let conversation_id = client.state.conversation_id; + let thread_id = client.state.thread_id; assert_eq!( client_metadata, std::collections::HashMap::from([ @@ -234,7 +293,7 @@ fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() { ), ( X_CODEX_WINDOW_ID_HEADER.to_string(), - format!("{conversation_id}:1"), + format!("{thread_id}:1"), ), ( X_OPENAI_SUBAGENT_HEADER.to_string(), @@ -316,6 +375,41 @@ async fn dropped_response_stream_traces_cancelled_partial_output() -> anyhow::Re Ok(()) } +#[tokio::test] +async fn response_stream_records_last_model_feedback_ids() { + let tags = Arc::new(Mutex::new(BTreeMap::new())); + let _guard = tracing_subscriber::registry() + .with(TagCollectorLayer { tags: tags.clone() }) + .set_default(); + + let api_stream = futures::stream::iter([ + Ok(ResponseEvent::Created), + Ok(ResponseEvent::Completed { + response_id: "resp-123".to_string(), + token_usage: None, + end_turn: Some(true), + }), + ]); + let (mut stream, _) = super::map_response_events( + Some("req-123".to_string()), + api_stream, + test_session_telemetry(), + InferenceTraceAttempt::disabled(), + ); + + while stream.next().await.is_some() {} + + let tags = tags.lock().unwrap().clone(); + assert_eq!( + tags.get("last_model_request_id").map(String::as_str), + Some("\"req-123\"") + ); + assert_eq!( + tags.get("last_model_response_id").map(String::as_str), + Some("\"resp-123\"") + ); +} + #[tokio::test] async fn dropped_backpressured_response_stream_traces_cancelled_partial_output() -> anyhow::Result<()> { @@ -383,3 +477,107 @@ fn auth_request_telemetry_context_tracks_attached_auth_and_retry_phase() { assert_eq!(auth_context.recovery_mode, Some("managed")); assert_eq!(auth_context.recovery_phase, Some("refresh_token")); } + +fn model_client_with_counting_attestation( + include_attestation: bool, +) -> (ModelClient, Arc) { + #[derive(Debug)] + struct CountingAttestationProvider { + calls: Arc, + } + + impl AttestationProvider for CountingAttestationProvider { + fn header_for_request( + &self, + _context: AttestationContext, + ) -> GenerateAttestationFuture<'_> { + let calls = self.calls.clone(); + Box::pin(async move { + let call = calls.fetch_add(1, Ordering::Relaxed) + 1; + Some(http::HeaderValue::from_bytes(format!("v1.header-{call}").as_bytes()).unwrap()) + }) + } + } + + let attestation_calls = Arc::new(AtomicUsize::new(0)); + let (auth_manager, provider) = if include_attestation { + ( + Some(AuthManager::from_auth_for_testing( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + )), + ModelProviderInfo::create_openai_provider(Some(CHATGPT_CODEX_BASE_URL.to_string())), + ) + } else { + ( + None, + create_oss_provider_with_base_url("https://example.com/v1", WireApi::Responses), + ) + }; + let model_client = ModelClient::new( + auth_manager, + SessionId::new(), + ThreadId::new(), + /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), + provider, + SessionSource::Exec, + /*model_verbosity*/ None, + /*enable_request_compression*/ false, + /*include_timing_metrics*/ false, + /*beta_features_header*/ None, + Some(Arc::new(CountingAttestationProvider { + calls: attestation_calls.clone(), + })), + ); + (model_client, attestation_calls) +} + +#[tokio::test] +async fn websocket_handshake_includes_attestation_for_chatgpt_codex_responses() { + let (model_client, attestation_calls) = + model_client_with_counting_attestation(/*include_attestation*/ true); + + let headers = model_client + .build_websocket_headers(/*turn_state*/ None, /*turn_metadata_header*/ None) + .await; + + assert_eq!( + headers + .get(crate::attestation::X_OAI_ATTESTATION_HEADER) + .and_then(|value| value.to_str().ok()), + Some("v1.header-1"), + ); + assert_eq!(attestation_calls.load(Ordering::Relaxed), 1); +} + +#[tokio::test] +async fn non_chatgpt_codex_endpoints_omit_attestation_generation() { + let (model_client, attestation_calls) = + model_client_with_counting_attestation(/*include_attestation*/ false); + let mut response_headers = http::HeaderMap::new(); + + if let Some(header_value) = model_client.generate_attestation_header_for().await { + response_headers.insert(crate::attestation::X_OAI_ATTESTATION_HEADER, header_value); + } + let mut compaction_headers = http::HeaderMap::new(); + if let Some(header_value) = model_client.generate_attestation_header_for().await { + compaction_headers.insert(crate::attestation::X_OAI_ATTESTATION_HEADER, header_value); + } + let mut realtime_headers = http::HeaderMap::new(); + if let Some(header_value) = model_client.generate_attestation_header_for().await { + realtime_headers.insert(crate::attestation::X_OAI_ATTESTATION_HEADER, header_value); + } + + assert_eq!( + response_headers.get(crate::attestation::X_OAI_ATTESTATION_HEADER), + None, + ); + assert_eq!( + compaction_headers.get(crate::attestation::X_OAI_ATTESTATION_HEADER), + None, + ); + assert_eq!( + realtime_headers.get(crate::attestation::X_OAI_ATTESTATION_HEADER), + None, + ); + assert_eq!(attestation_calls.load(Ordering::Relaxed), 0); +} diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index d142d33a2f..5a01384bf0 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -16,6 +16,7 @@ use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::Submission; +use codex_protocol::protocol::ThreadSource; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionsArgs; use codex_protocol::request_permissions::RequestPermissionsEvent; @@ -30,7 +31,6 @@ use tokio::time::timeout; use tokio_util::sync::CancellationToken; use crate::config::Config; -use crate::environment_selection::ResolvedTurnEnvironments; use crate::guardian::GuardianApprovalRequest; use crate::guardian::new_guardian_review_id; use crate::guardian::routes_approval_to_guardian; @@ -76,15 +76,17 @@ pub(crate) async fn run_codex_thread_interactive( let (tx_ops, rx_ops) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let CodexSpawnOk { codex, .. } = Box::pin(Codex::spawn(CodexSpawnArgs { config, + installation_id: parent_session.installation_id.clone(), auth_manager, models_manager, environment_manager: Arc::clone(&parent_session.services.environment_manager), skills_manager: Arc::clone(&parent_session.services.skills_manager), plugins_manager: Arc::clone(&parent_session.services.plugins_manager), mcp_manager: Arc::clone(&parent_session.services.mcp_manager), - skills_watcher: Arc::clone(&parent_session.services.skills_watcher), + extensions: Arc::clone(&parent_session.services.extensions), conversation_history: initial_history.unwrap_or(InitialHistory::New), session_source: SessionSource::SubAgent(subagent_source.clone()), + thread_source: Some(ThreadSource::Subagent), agent_control: parent_session.services.agent_control.clone(), dynamic_tools: Vec::new(), persist_extended_history: false, @@ -94,11 +96,10 @@ pub(crate) async fn run_codex_thread_interactive( inherited_exec_policy: Some(Arc::clone(&parent_session.services.exec_policy)), parent_rollout_thread_trace: codex_rollout_trace::ThreadTraceContext::disabled(), parent_trace: None, - environment_selections: ResolvedTurnEnvironments { - turn_environments: parent_ctx.environments.clone(), - }, + environment_selections: parent_ctx.environments.clone(), analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), thread_store: Arc::clone(&parent_session.services.thread_store), + attestation_provider: parent_session.services.attestation_provider.clone(), })) .or_cancel(&cancel_token) .await??; @@ -268,10 +269,6 @@ async fn forward_events( id: _, msg: EventMsg::SessionConfigured(_), } => {} - Event { - id: _, - msg: EventMsg::ThreadNameUpdated(_), - } => {} Event { id, msg: EventMsg::ExecApprovalRequest(event), diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 84224ea2d5..ecd392e3e7 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -225,6 +225,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { RequestPermissionsEvent { call_id: request_call_id, turn_id: "child-turn-1".to_string(), + started_at_ms: 0, reason: Some("need access".to_string()), permissions: RequestPermissionProfile { network: Some(NetworkPermissions { @@ -313,6 +314,7 @@ async fn handle_exec_approval_uses_call_id_for_guardian_review_and_approval_id_f call_id: "command-item-1".to_string(), approval_id: Some("callback-approval-1".to_string()), turn_id: "child-turn-1".to_string(), + started_at_ms: 0, command: vec!["rm".to_string(), "-rf".to_string(), "tmp".to_string()], cwd: test_path_buf("/tmp").abs(), reason: Some("unsafe subcommand".to_string()), diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index cc83c0a7c1..c110d30f66 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -1,16 +1,16 @@ use crate::agent::AgentStatus; use crate::config::ConstraintResult; -use crate::file_watcher::WatchRegistration; +use crate::goals::ExternalGoalSet; use crate::goals::GoalRuntimeEvent; use crate::session::Codex; use crate::session::SessionSettingsUpdate; use crate::session::SteerInputError; use codex_features::Feature; +use codex_otel::SessionTelemetry; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; @@ -29,10 +29,14 @@ use codex_protocol::protocol::SessionConfiguredEvent; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::Submission; use codex_protocol::protocol::ThreadMemoryMode; +use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TokenUsageInfo; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::user_input::UserInput; +use codex_thread_store::StoredThread; use codex_thread_store::StoredThreadHistory; +use codex_thread_store::ThreadMetadataPatch; use codex_thread_store::ThreadStoreError; use codex_thread_store::ThreadStoreResult; use codex_utils_absolute_path::AbsolutePathBuf; @@ -49,7 +53,7 @@ use codex_rollout::state_db::StateDbHandle; pub struct ThreadConfigSnapshot { pub model: String, pub model_provider_id: String, - pub service_tier: Option, + pub service_tier: Option, pub approval_policy: AskForApproval, pub approvals_reviewer: ApprovalsReviewer, pub permission_profile: PermissionProfile, @@ -59,6 +63,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_effort: Option, pub personality: Option, pub session_source: SessionSource, + pub thread_source: Option, } impl ThreadConfigSnapshot { @@ -86,7 +91,7 @@ pub struct CodexThreadTurnContextOverrides { pub model: Option, pub effort: Option>, pub summary: Option, - pub service_tier: Option>, + pub service_tier: Option>, pub collaboration_mode: Option, pub personality: Option, } @@ -97,7 +102,6 @@ pub struct CodexThread { session_configured: SessionConfiguredEvent, rollout_path: Option, out_of_band_elicitation_count: Mutex, - _watch_registration: WatchRegistration, } /// Conduit for the bidirectional stream of messages that compose a thread @@ -108,7 +112,6 @@ impl CodexThread { session_configured: SessionConfiguredEvent, rollout_path: Option, session_source: SessionSource, - watch_registration: WatchRegistration, ) -> Self { Self { codex, @@ -116,7 +119,6 @@ impl CodexThread { session_configured, rollout_path, out_of_band_elicitation_count: Mutex::new(0), - _watch_registration: watch_registration, } } @@ -124,6 +126,11 @@ impl CodexThread { self.codex.submit(op).await } + /// Returns the session telemetry handle for thread-scoped production instrumentation. + pub fn session_telemetry(&self) -> SessionTelemetry { + self.codex.session.services.session_telemetry.clone() + } + pub async fn shutdown_and_wait(&self) -> CodexResult<()> { self.codex.shutdown_and_wait().await } @@ -158,11 +165,11 @@ impl CodexThread { } } - pub async fn apply_external_goal_set(&self, status: codex_state::ThreadGoalStatus) { + pub async fn apply_external_goal_set(&self, external_set: ExternalGoalSet) { if let Err(err) = self .codex .session - .goal_runtime_apply(GoalRuntimeEvent::ExternalSet { status }) + .goal_runtime_apply(GoalRuntimeEvent::ExternalSet { external_set }) .await { tracing::warn!("failed to apply external goal status runtime effects: {err}"); @@ -218,9 +225,14 @@ impl CodexThread { &self, app_server_client_name: Option, app_server_client_version: Option, + mcp_elicitations_auto_deny: bool, ) -> ConstraintResult<()> { self.codex - .set_app_server_client_info(app_server_client_name, app_server_client_version) + .set_app_server_client_info( + app_server_client_name, + app_server_client_version, + mcp_elicitations_auto_deny, + ) .await } @@ -381,7 +393,7 @@ impl CodexThread { self.rollout_path.clone() } - pub(crate) fn session_configured(&self) -> SessionConfiguredEvent { + pub fn session_configured(&self) -> SessionConfiguredEvent { self.session_configured.clone() } @@ -411,6 +423,38 @@ impl CodexThread { live_thread.load_history(include_archived).await } + pub async fn read_thread( + &self, + include_archived: bool, + include_history: bool, + ) -> ThreadStoreResult { + let live_thread = self + .codex + .session + .live_thread_for_persistence("read thread") + .map_err(|err| ThreadStoreError::Internal { + message: err.to_string(), + })?; + live_thread + .read_thread(include_archived, include_history) + .await + } + + pub async fn update_thread_metadata( + &self, + patch: ThreadMetadataPatch, + include_archived: bool, + ) -> ThreadStoreResult { + let live_thread = self + .codex + .session + .live_thread_for_persistence("update thread metadata") + .map_err(|err| ThreadStoreError::Internal { + message: err.to_string(), + })?; + live_thread.update_metadata(patch, include_archived).await + } + pub fn state_db(&self) -> Option { self.codex.state_db() } @@ -423,6 +467,17 @@ impl CodexThread { self.codex.session.get_config().await } + /// Refresh the thread's layer-backed user config state from a caller-supplied + /// config snapshot. Thread-scoped layers and session-static settings remain + /// unchanged. + pub async fn refresh_runtime_config(&self, next_config: crate::config::Config) { + self.codex.session.refresh_runtime_config(next_config).await; + } + + pub async fn environment_selections(&self) -> Vec { + self.codex.thread_environment_selections().await + } + pub async fn read_mcp_resource( &self, server: &str, diff --git a/codex-rs/core/src/commit_attribution.rs b/codex-rs/core/src/commit_attribution.rs deleted file mode 100644 index 8df9661431..0000000000 --- a/codex-rs/core/src/commit_attribution.rs +++ /dev/null @@ -1,33 +0,0 @@ -const DEFAULT_ATTRIBUTION_VALUE: &str = "Codex "; - -fn build_commit_message_trailer(config_attribution: Option<&str>) -> Option { - let value = resolve_attribution_value(config_attribution)?; - Some(format!("Co-authored-by: {value}")) -} - -pub(crate) fn commit_message_trailer_instruction( - config_attribution: Option<&str>, -) -> Option { - let trailer = build_commit_message_trailer(config_attribution)?; - Some(format!( - "When you write or edit a git commit message, ensure the message ends with this trailer exactly once:\n{trailer}\n\nRules:\n- Keep existing trailers and append this trailer at the end if missing.\n- Do not duplicate this trailer if it already exists.\n- Keep one blank line between the commit body and trailer block." - )) -} - -fn resolve_attribution_value(config_attribution: Option<&str>) -> Option { - match config_attribution { - Some(value) => { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - } - None => Some(DEFAULT_ATTRIBUTION_VALUE.to_string()), - } -} - -#[cfg(test)] -#[path = "commit_attribution_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/commit_attribution_tests.rs b/codex-rs/core/src/commit_attribution_tests.rs deleted file mode 100644 index 8b2ce27e1f..0000000000 --- a/codex-rs/core/src/commit_attribution_tests.rs +++ /dev/null @@ -1,43 +0,0 @@ -use super::build_commit_message_trailer; -use super::commit_message_trailer_instruction; -use super::resolve_attribution_value; - -#[test] -fn blank_attribution_disables_trailer_prompt() { - assert_eq!(build_commit_message_trailer(Some("")), None); - assert_eq!(commit_message_trailer_instruction(Some(" ")), None); -} - -#[test] -fn default_attribution_uses_codex_trailer() { - assert_eq!( - build_commit_message_trailer(/*config_attribution*/ None).as_deref(), - Some("Co-authored-by: Codex ") - ); -} - -#[test] -fn resolve_value_handles_default_custom_and_blank() { - assert_eq!( - resolve_attribution_value(/*config_attribution*/ None), - Some("Codex ".to_string()) - ); - assert_eq!( - resolve_attribution_value(Some("MyAgent ")), - Some("MyAgent ".to_string()) - ); - assert_eq!( - resolve_attribution_value(Some("MyAgent")), - Some("MyAgent".to_string()) - ); - assert_eq!(resolve_attribution_value(Some(" ")), None); -} - -#[test] -fn instruction_mentions_trailer_and_omits_generated_with() { - let instruction = commit_message_trailer_instruction(Some("AgentX ")) - .expect("instruction expected"); - assert!(instruction.contains("Co-authored-by: AgentX ")); - assert!(instruction.contains("exactly once")); - assert!(!instruction.contains("Generated-with")); -} diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 58a2610fcb..9d1c82eb65 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -4,6 +4,10 @@ use std::time::Instant; use crate::Prompt; use crate::client::ModelClientSession; use crate::client_common::ResponseEvent; +use crate::hook_runtime::PostCompactHookOutcome; +use crate::hook_runtime::PreCompactHookOutcome; +use crate::hook_runtime::run_post_compact_hooks; +use crate::hook_runtime::run_pre_compact_hooks; #[cfg(test)] use crate::session::PreviousTurnSettings; use crate::session::session::Session; @@ -110,7 +114,8 @@ pub(crate) async fn run_compact_task( CompactionReason::UserRequested, CompactionPhase::StandaloneTurn, ) - .await + .await?; + Ok(()) } async fn run_compact_task_inner( @@ -131,6 +136,17 @@ async fn run_compact_task_inner( phase, ) .await; + let pre_compact_outcome = run_pre_compact_hooks(&sess, &turn_context, trigger).await; + match pre_compact_outcome { + PreCompactHookOutcome::Continue => {} + PreCompactHookOutcome::Stopped { reason } => { + let error = reason.unwrap_or_else(|| "PreCompact hook stopped execution".to_string()); + attempt + .track(sess.as_ref(), CompactionStatus::Interrupted, Some(error)) + .await; + return Err(CodexErr::TurnAborted); + } + } let result = run_compact_task_inner_impl( Arc::clone(&sess), Arc::clone(&turn_context), @@ -138,14 +154,17 @@ async fn run_compact_task_inner( initial_context_injection, ) .await; - attempt - .track( - sess.as_ref(), - compaction_status_from_result(&result), - result.as_ref().err().map(ToString::to_string), - ) - .await; - result + let status = compaction_status_from_result(&result); + let error = result.as_ref().err().map(ToString::to_string); + if result.is_ok() { + let post_compact_outcome = run_post_compact_hooks(&sess, &turn_context, trigger).await; + if let PostCompactHookOutcome::Stopped = post_compact_outcome { + attempt.track(sess.as_ref(), status, error).await; + return Err(CodexErr::TurnAborted); + } + } + attempt.track(sess.as_ref(), status, error).await; + result.map(|_| ()) } async fn run_compact_task_inner_impl( @@ -153,7 +172,7 @@ async fn run_compact_task_inner_impl( turn_context: Arc, input: Vec, initial_context_injection: InitialContextInjection, -) -> CodexResult<()> { +) -> CodexResult { let compaction_item = TurnItem::ContextCompaction(ContextCompactionItem::new()); sess.emit_turn_item_started(&turn_context, &compaction_item) .await; @@ -272,7 +291,7 @@ async fn run_compact_task_inner_impl( message: "Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted.".to_string(), }); sess.send_event(&turn_context, warning).await; - Ok(()) + Ok(summary_suffix) } pub(crate) struct CompactionAnalyticsAttempt { @@ -420,7 +439,13 @@ pub(crate) fn insert_initial_context_before_last_real_user_or_summary( .iter() .enumerate() .rev() - .find_map(|(i, item)| matches!(item, ResponseItem::Compaction { .. }).then_some(i)); + .find_map(|(i, item)| { + matches!( + item, + ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } + ) + .then_some(i) + }); let insertion_index = last_real_user_index .or(last_user_or_summary_index) .or(last_compaction_index); @@ -518,7 +543,7 @@ async fn drain_to_completed( &turn_context.session_telemetry, turn_context.reasoning_effort, turn_context.reasoning_summary, - turn_context.config.service_tier, + turn_context.config.service_tier.clone(), turn_metadata_header, // Rollout tracing currently models remote compaction only; local compaction streams // are left untraced until the reducer has a first-class local compaction lifecycle. diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index d8adb20772..cc31d50b13 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -2,6 +2,7 @@ use std::collections::HashSet; use std::sync::Arc; use crate::Prompt; +use crate::client::CompactConversationRequestSettings; use crate::compact::CompactionAnalyticsAttempt; use crate::compact::InitialContextInjection; use crate::compact::compaction_status_from_result; @@ -10,6 +11,10 @@ use crate::context_manager::ContextManager; use crate::context_manager::TotalTokenUsageBreakdown; use crate::context_manager::estimate_response_item_model_visible_bytes; use crate::context_manager::is_codex_generated_item; +use crate::hook_runtime::PostCompactHookOutcome; +use crate::hook_runtime::PreCompactHookOutcome; +use crate::hook_runtime::run_post_compact_hooks; +use crate::hook_runtime::run_pre_compact_hooks; use crate::session::session::Session; use crate::session::turn::built_tools; use crate::session::turn_context::TurnContext; @@ -17,6 +22,7 @@ use codex_analytics::CompactionImplementation; use codex_analytics::CompactionPhase; use codex_analytics::CompactionReason; use codex_analytics::CompactionTrigger; +use codex_app_server_protocol::AuthMode; use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; @@ -71,7 +77,8 @@ pub(crate) async fn run_remote_compact_task( CompactionReason::UserRequested, CompactionPhase::StandaloneTurn, ) - .await + .await?; + Ok(()) } async fn run_remote_compact_task_inner( @@ -91,15 +98,33 @@ async fn run_remote_compact_task_inner( phase, ) .await; + let pre_compact_outcome = run_pre_compact_hooks(sess, turn_context, trigger).await; + match pre_compact_outcome { + PreCompactHookOutcome::Continue => {} + PreCompactHookOutcome::Stopped { reason } => { + let error = reason.unwrap_or_else(|| "PreCompact hook stopped execution".to_string()); + attempt + .track( + sess.as_ref(), + codex_analytics::CompactionStatus::Interrupted, + Some(error), + ) + .await; + return Err(CodexErr::TurnAborted); + } + } let result = run_remote_compact_task_inner_impl(sess, turn_context, initial_context_injection).await; - attempt - .track( - sess.as_ref(), - compaction_status_from_result(&result), - result.as_ref().err().map(ToString::to_string), - ) - .await; + let status = compaction_status_from_result(&result); + let error = result.as_ref().err().map(ToString::to_string); + if result.is_ok() { + let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await; + if let PostCompactHookOutcome::Stopped = post_compact_outcome { + attempt.track(sess.as_ref(), status, error).await; + return Err(CodexErr::TurnAborted); + } + } + attempt.track(sess.as_ref(), status, error.clone()).await; if let Err(err) = result { let event = EventMsg::Error( err.to_error_event(Some("Error running remote compact task".to_string())), @@ -170,8 +195,15 @@ async fn run_remote_compact_task_inner_impl( .compact_conversation_history( &prompt, &turn_context.model_info, - turn_context.reasoning_effort, - turn_context.reasoning_summary, + CompactConversationRequestSettings { + effort: turn_context.reasoning_effort, + summary: turn_context.reasoning_summary, + service_tier: if sess.services.auth_manager.auth_mode() == Some(AuthMode::ApiKey) { + None + } else { + turn_context.config.service_tier.clone() + }, + }, &turn_context.session_telemetry, &compaction_trace, ) @@ -255,8 +287,9 @@ pub(crate) async fn process_compacted_history( /// /// This intentionally keeps: /// - `assistant` messages (future remote compaction models may emit them) -/// - `user`-role warnings and compaction-generated summary messages because -/// they parse as `TurnItem::UserMessage`. +/// - `user`-role warnings that parse as `TurnItem::UserMessage` and compaction-generated summary +/// messages. Legacy warning fragments are filtered by `parse_turn_item` before they reach this +/// check. fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { match item { ResponseItem::Message { role, .. } if role == "developer" => false, @@ -268,7 +301,7 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { } ResponseItem::Message { role, .. } if role == "assistant" => true, ResponseItem::Message { .. } => false, - ResponseItem::Compaction { .. } => true, + ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } @@ -284,11 +317,11 @@ fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { } #[derive(Debug)] -struct CompactRequestLogData { +pub(crate) struct CompactRequestLogData { failing_compaction_request_model_visible_bytes: i64, } -fn build_compact_request_log_data( +pub(crate) fn build_compact_request_log_data( input: &[ResponseItem], instructions: &str, ) -> CompactRequestLogData { @@ -305,7 +338,7 @@ fn build_compact_request_log_data( } } -fn log_remote_compact_failure( +pub(crate) fn log_remote_compact_failure( turn_context: &TurnContext, log_data: &CompactRequestLogData, total_usage_breakdown: TotalTokenUsageBreakdown, @@ -324,7 +357,7 @@ fn log_remote_compact_failure( ); } -fn trim_function_call_history_to_fit_context_window( +pub(crate) fn trim_function_call_history_to_fit_context_window( history: &mut ContextManager, turn_context: &TurnContext, base_instructions: &BaseInstructions, diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs new file mode 100644 index 0000000000..7f6ea61a5b --- /dev/null +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -0,0 +1,456 @@ +use std::collections::HashSet; +use std::sync::Arc; + +use crate::Prompt; +use crate::ResponseStream; +use crate::client::ModelClientSession; +use crate::client_common::ResponseEvent; +use crate::compact::CompactionAnalyticsAttempt; +use crate::compact::InitialContextInjection; +use crate::compact::compaction_status_from_result; +use crate::compact_remote::build_compact_request_log_data; +use crate::compact_remote::log_remote_compact_failure; +use crate::compact_remote::process_compacted_history; +use crate::compact_remote::trim_function_call_history_to_fit_context_window; +use crate::session::session::Session; +use crate::session::turn::built_tools; +use crate::session::turn_context::TurnContext; +use codex_analytics::CompactionImplementation; +use codex_analytics::CompactionPhase; +use codex_analytics::CompactionReason; +use codex_analytics::CompactionTrigger; +use codex_features::Feature; +use codex_protocol::error::CodexErr; +use codex_protocol::error::Result as CodexResult; +use codex_protocol::items::ContextCompactionItem; +use codex_protocol::items::TurnItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::CompactedItem; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::TurnStartedEvent; +use codex_rollout_trace::CompactionCheckpointTracePayload; +use codex_rollout_trace::InferenceTraceContext; +use futures::StreamExt; +use futures::TryFutureExt; +use tokio_util::sync::CancellationToken; +use tracing::info; + +pub(crate) async fn run_inline_remote_auto_compact_task( + sess: Arc, + turn_context: Arc, + client_session: &mut ModelClientSession, + initial_context_injection: InitialContextInjection, + reason: CompactionReason, + phase: CompactionPhase, +) -> CodexResult<()> { + run_remote_compact_task_inner( + &sess, + &turn_context, + Some(client_session), + initial_context_injection, + CompactionTrigger::Auto, + reason, + phase, + ) + .await +} + +pub(crate) async fn run_remote_compact_task( + sess: Arc, + turn_context: Arc, +) -> CodexResult<()> { + let start_event = EventMsg::TurnStarted(TurnStartedEvent { + turn_id: turn_context.sub_id.clone(), + started_at: turn_context.turn_timing_state.started_at_unix_secs().await, + model_context_window: turn_context.model_context_window(), + collaboration_mode_kind: turn_context.collaboration_mode.mode, + }); + sess.send_event(&turn_context, start_event).await; + + run_remote_compact_task_inner( + &sess, + &turn_context, + /*client_session*/ None, + InitialContextInjection::DoNotInject, + CompactionTrigger::Manual, + CompactionReason::UserRequested, + CompactionPhase::StandaloneTurn, + ) + .await +} + +async fn run_remote_compact_task_inner( + sess: &Arc, + turn_context: &Arc, + client_session: Option<&mut ModelClientSession>, + initial_context_injection: InitialContextInjection, + trigger: CompactionTrigger, + reason: CompactionReason, + phase: CompactionPhase, +) -> CodexResult<()> { + let attempt = CompactionAnalyticsAttempt::begin( + sess.as_ref(), + turn_context.as_ref(), + trigger, + reason, + CompactionImplementation::Responses, + phase, + ) + .await; + let result = run_remote_compact_task_inner_impl( + sess, + turn_context, + client_session, + initial_context_injection, + ) + .await; + attempt + .track( + sess.as_ref(), + compaction_status_from_result(&result), + result.as_ref().err().map(ToString::to_string), + ) + .await; + if let Err(err) = result { + let event = EventMsg::Error( + err.to_error_event(Some("Error running remote compact task".to_string())), + ); + sess.send_event(turn_context, event).await; + return Err(err); + } + Ok(()) +} + +async fn run_remote_compact_task_inner_impl( + sess: &Arc, + turn_context: &Arc, + client_session: Option<&mut ModelClientSession>, + initial_context_injection: InitialContextInjection, +) -> CodexResult<()> { + let context_compaction_item = ContextCompactionItem::new(); + let compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context( + turn_context.sub_id.as_str(), + context_compaction_item.id.as_str(), + turn_context.model_info.slug.as_str(), + turn_context.provider.info().name.as_str(), + ); + let compaction_item = TurnItem::ContextCompaction(context_compaction_item); + sess.emit_turn_item_started(turn_context, &compaction_item) + .await; + + let mut history = sess.clone_history().await; + let base_instructions = sess.get_base_instructions().await; + let deleted_items = trim_function_call_history_to_fit_context_window( + &mut history, + turn_context.as_ref(), + &base_instructions, + ); + if deleted_items > 0 { + info!( + turn_id = %turn_context.sub_id, + deleted_items, + "trimmed history items before remote compaction v2" + ); + } + + let trace_input_history = history.raw_items().to_vec(); + let prompt_input = history.for_prompt(&turn_context.model_info.input_modalities); + let tool_router = built_tools( + sess.as_ref(), + turn_context.as_ref(), + &prompt_input, + &HashSet::new(), + /*skills_outcome*/ None, + &CancellationToken::new(), + ) + .await?; + let mut input = prompt_input.clone(); + input.push(ResponseItem::ContextCompaction { + encrypted_content: None, + }); + let prompt = Prompt { + input, + tools: tool_router.model_visible_specs(), + parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls, + base_instructions, + personality: turn_context.personality, + output_schema: None, + output_schema_strict: true, + }; + + let turn_metadata_header = turn_context.turn_metadata_state.current_header_value(); + let trace_attempt = compaction_trace.start_attempt(&serde_json::json!({ + "model": turn_context.model_info.slug.as_str(), + "instructions": prompt.base_instructions.text.as_str(), + "input": &prompt.input, + "parallel_tool_calls": prompt.parallel_tool_calls, + })); + + let mut owned_client_session; + let client_session = match client_session { + Some(client_session) => client_session, + None => { + owned_client_session = sess.services.model_client.new_session(); + &mut owned_client_session + } + }; + let compaction_output_result = run_remote_compaction_request_v2( + sess, + turn_context, + client_session, + &prompt, + turn_metadata_header.as_deref(), + ) + .await; + + trace_attempt.record_result( + compaction_output_result + .as_ref() + .map(|(item, _)| std::slice::from_ref(item)), + ); + let (compaction_output, response_id) = compaction_output_result?; + let compacted_history = build_v2_compacted_history(&prompt_input, compaction_output); + let new_history = process_compacted_history( + sess.as_ref(), + turn_context.as_ref(), + compacted_history, + initial_context_injection, + ) + .await; + + let reference_context_item = match initial_context_injection { + InitialContextInjection::DoNotInject => None, + InitialContextInjection::BeforeLastUserMessage => Some(turn_context.to_turn_context_item()), + }; + let compacted_item = CompactedItem { + message: String::new(), + replacement_history: Some(new_history.clone()), + }; + compaction_trace.record_installed(&CompactionCheckpointTracePayload { + input_history: &trace_input_history, + replacement_history: &new_history, + }); + sess.replace_compacted_history(new_history, reference_context_item, compacted_item) + .await; + sess.recompute_token_usage(turn_context).await; + + sess.emit_turn_item_completed(turn_context, compaction_item) + .await; + if turn_context + .features + .enabled(Feature::ResponsesWebsocketResponseProcessed) + { + client_session.send_response_processed(&response_id).await; + } + Ok(()) +} + +async fn run_remote_compaction_request_v2( + sess: &Session, + turn_context: &TurnContext, + client_session: &mut ModelClientSession, + prompt: &Prompt, + turn_metadata_header: Option<&str>, +) -> CodexResult<(ResponseItem, String)> { + let stream = client_session + .stream( + prompt, + &turn_context.model_info, + &turn_context.session_telemetry, + turn_context.reasoning_effort, + turn_context.reasoning_summary, + turn_context.config.service_tier.clone(), + turn_metadata_header, + &InferenceTraceContext::disabled(), + ) + .or_else(|err| async { + let total_usage_breakdown = sess.get_total_token_usage_breakdown().await; + let compact_request_log_data = + build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text); + log_remote_compact_failure( + turn_context, + &compact_request_log_data, + total_usage_breakdown, + &err, + ); + Err(err) + }) + .await?; + collect_context_compaction_output(stream).await +} + +async fn collect_context_compaction_output( + mut stream: ResponseStream, +) -> CodexResult<(ResponseItem, String)> { + let mut output_item_count = 0usize; + let mut context_compaction_count = 0usize; + let mut context_compaction_output = None; + let mut completed_response_id = None; + while let Some(event) = stream.next().await { + match event? { + ResponseEvent::OutputItemDone(item) => { + output_item_count += 1; + match item { + ResponseItem::ContextCompaction { + encrypted_content: Some(_), + } => { + context_compaction_count += 1; + if context_compaction_output.is_none() { + context_compaction_output = Some(item); + } + } + ResponseItem::ContextCompaction { + encrypted_content: None, + } => { + return Err(CodexErr::Fatal( + "remote compaction v2 returned context_compaction without encrypted_content" + .to_string(), + )); + } + _ => {} + } + } + ResponseEvent::Completed { response_id, .. } => { + completed_response_id = Some(response_id); + break; + } + _ => {} + } + } + + let Some(response_id) = completed_response_id else { + return Err(CodexErr::Fatal( + "remote compaction v2 stream closed before response.completed".to_string(), + )); + }; + + if context_compaction_count != 1 { + return Err(CodexErr::Fatal(format!( + "remote compaction v2 expected exactly one context_compaction output item, got {context_compaction_count} from {output_item_count} output items" + ))); + } + + let Some(context_compaction_output) = context_compaction_output else { + unreachable!("context compaction output must exist when count is exactly one"); + }; + Ok((context_compaction_output, response_id)) +} + +fn build_v2_compacted_history( + prompt_input: &[ResponseItem], + compaction_output: ResponseItem, +) -> Vec { + let mut retained = prompt_input + .iter() + .filter(|item| is_retained_for_remote_compaction_v2(item)) + .cloned() + .collect::>(); + retained.push(compaction_output); + retained +} + +fn is_retained_for_remote_compaction_v2(item: &ResponseItem) -> bool { + let ResponseItem::Message { role, .. } = item else { + return false; + }; + + matches!(role.as_str(), "user" | "developer" | "system") +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::models::ContentItem; + use codex_protocol::models::MessagePhase; + use pretty_assertions::assert_eq; + use tokio::sync::mpsc; + use tokio_util::sync::CancellationToken; + + fn message(role: &str, text: &str, phase: Option) -> ResponseItem { + ResponseItem::Message { + id: None, + role: role.to_string(), + content: vec![ContentItem::InputText { + text: text.to_string(), + }], + phase, + } + } + + fn response_stream(events: Vec>) -> ResponseStream { + let (tx_event, rx_event) = mpsc::channel(events.len().max(1)); + for event in events { + tx_event + .try_send(event) + .expect("response stream test channel should have capacity"); + } + drop(tx_event); + ResponseStream { + rx_event, + consumer_dropped: CancellationToken::new(), + } + } + + #[test] + fn build_v2_compacted_history_matches_prod_retention_shape() { + let input = vec![ + message("developer", "dev", /*phase*/ None), + message("system", "sys", /*phase*/ None), + message("user", "user", /*phase*/ None), + message("assistant", "commentary", Some(MessagePhase::Commentary)), + message("assistant", "final", Some(MessagePhase::FinalAnswer)), + ResponseItem::FunctionCall { + id: None, + name: "shell".to_string(), + namespace: None, + arguments: "{}".to_string(), + call_id: "call_1".to_string(), + }, + ResponseItem::Compaction { + encrypted_content: "old".to_string(), + }, + ]; + let output = ResponseItem::ContextCompaction { + encrypted_content: Some("new".to_string()), + }; + + let history = build_v2_compacted_history(&input, output.clone()); + + assert_eq!( + history, + vec![ + message("developer", "dev", /*phase*/ None), + message("system", "sys", /*phase*/ None), + message("user", "user", /*phase*/ None), + output, + ] + ); + } + + #[tokio::test] + async fn collect_context_compaction_output_accepts_additional_output_items() { + let context_compaction = ResponseItem::ContextCompaction { + encrypted_content: Some("encrypted".to_string()), + }; + let stream = response_stream(vec![ + Ok(ResponseEvent::OutputItemDone(message( + "assistant", + "IGNORED_COMPACT_REPLY", + Some(MessagePhase::FinalAnswer), + ))), + Ok(ResponseEvent::OutputItemDone(context_compaction.clone())), + Ok(ResponseEvent::Completed { + response_id: "resp-compact".to_string(), + token_usage: None, + end_turn: Some(true), + }), + ]); + + let (output, response_id) = collect_context_compaction_output(stream) + .await + .expect("context compaction should be collected"); + + assert_eq!(output, context_compaction); + assert_eq!(response_id, "resp-compact"); + } +} diff --git a/codex-rs/core/src/compact_tests.rs b/codex-rs/core/src/compact_tests.rs index 8fdb7fb4b2..69bd95f2df 100644 --- a/codex-rs/core/src/compact_tests.rs +++ b/codex-rs/core/src/compact_tests.rs @@ -23,6 +23,17 @@ async fn process_compacted_history_with_test_session( (refreshed, initial_context) } +fn user_message(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: text.to_string(), + }], + phase: None, + } +} + #[test] fn content_items_to_text_joins_non_empty_segments() { let items = vec![ @@ -120,6 +131,26 @@ do things assert_eq!(vec!["real user message".to_string()], collected); } +#[test] +fn collect_user_messages_filters_legacy_warnings() { + let items = vec![ + user_message( + "Warning: The maximum number of unified exec processes you can keep open is 60 and you currently have 61 processes open. Reuse older processes or close them to prevent automatic pruning of old processes", + ), + user_message( + "Warning: apply_patch was requested via exec_command. Use the apply_patch tool instead of exec_command.", + ), + user_message( + "Warning: Your account was flagged for potentially high-risk cyber activity and this request was routed to gpt-5.2 as a fallback. To regain access to gpt-5.3-codex, apply for trusted access: https://chatgpt.com/cyber or learn more: https://developers.openai.com/codex/concepts/cyber-safety", + ), + user_message("real user message"), + ]; + + let collected = collect_user_messages(&items); + + assert_eq!(vec!["real user message".to_string()], collected); +} + #[test] fn build_token_limited_compacted_history_truncates_overlong_user_messages() { // Use a small truncation limit so the test remains fast while still validating @@ -208,7 +239,6 @@ fn should_use_remote_compact_task_for_azure_provider() { assert!(should_use_remote_compact_task(&provider)); } - #[tokio::test] async fn process_compacted_history_replaces_developer_messages() { let compacted_history = vec![ @@ -352,6 +382,31 @@ keep me updated assert_eq!(refreshed, expected); } +#[tokio::test] +async fn process_compacted_history_drops_legacy_warnings() { + let latest_user = user_message("latest user"); + let compacted_history = vec![ + user_message( + "Warning: The maximum number of unified exec processes you can keep open is 60 and you currently have 61 processes open. Reuse older processes or close them to prevent automatic pruning of old processes", + ), + user_message( + "Warning: apply_patch was requested via exec_command. Use the apply_patch tool instead of exec_command.", + ), + user_message( + "Warning: Your account was flagged for potentially high-risk cyber activity and this request was routed to gpt-5.2 as a fallback. To regain access to gpt-5.3-codex, apply for trusted access: https://chatgpt.com/cyber or learn more: https://developers.openai.com/codex/concepts/cyber-safety", + ), + latest_user.clone(), + ]; + let (refreshed, initial_context) = process_compacted_history_with_test_session( + compacted_history, + /*previous_turn_settings*/ None, + ) + .await; + let mut expected = initial_context; + expected.push(latest_user); + assert_eq!(refreshed, expected); +} + #[tokio::test] async fn process_compacted_history_inserts_context_before_last_real_user_message_only() { let compacted_history = vec![ diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 6fcd5f872d..9eec97773d 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -23,7 +23,6 @@ use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; use codex_config::loader::load_config_layers_state; use codex_config::loader::load_requirements_toml; -use codex_config::version_for_toml; use codex_exec_server::LOCAL_FS; use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WebSearchMode; @@ -68,6 +67,45 @@ async fn make_config_for_test( .await } +async fn write_linked_worktree_pointer( + repo_root: &Path, + worktree_root: &Path, +) -> std::io::Result<()> { + let worktree_git_dir = repo_root.join(".git/worktrees/feature-x"); + tokio::fs::create_dir_all(&worktree_git_dir).await?; + tokio::fs::write( + worktree_root.join(".git"), + format!("gitdir: {}\n", worktree_git_dir.display()), + ) + .await +} + +async fn write_project_hook_config( + dot_codex_folder: &Path, + foo: Option<&str>, + command: &str, +) -> std::io::Result<()> { + tokio::fs::create_dir_all(dot_codex_folder).await?; + let foo = foo + .map(|value| format!("foo = \"{value}\"\n\n")) + .unwrap_or_default(); + tokio::fs::write( + dot_codex_folder.join(CONFIG_TOML_FILE), + format!( + r#"{foo}[hooks] + +[[hooks.PreToolUse]] +matcher = "Bash" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "{command}" +"# + ), + ) + .await +} + #[tokio::test] async fn cli_overrides_resolve_relative_paths_against_cwd() -> std::io::Result<()> { let codex_home = tempdir().expect("tempdir"); @@ -228,6 +266,76 @@ async fn returns_config_error_for_schema_error_in_user_config() { assert_eq!(config_error, &expected_config_error); } +#[tokio::test] +async fn top_level_allow_managed_hooks_only_in_user_config_does_not_enable_requirements_policy() +-> std::io::Result<()> { + let tmp = tempdir().expect("tempdir"); + std::fs::write( + tmp.path().join(CONFIG_TOML_FILE), + "allow_managed_hooks_only = true", + ) + .expect("write config"); + + let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + tmp.path(), + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, + ) + .await?; + + assert_eq!(layers.requirements_toml().allow_managed_hooks_only, None); + assert!(layers.requirements().allow_managed_hooks_only.is_none()); + + Ok(()) +} + +#[tokio::test] +async fn hooks_allow_managed_hooks_only_in_user_config_does_not_enable_requirements_policy() +-> std::io::Result<()> { + let tmp = tempdir().expect("tempdir"); + let contents = r#" +[hooks] +allow_managed_hooks_only = true + +[[hooks.PreToolUse]] +matcher = "^Bash$" + +[[hooks.PreToolUse.hooks]] +type = "command" +command = "python3 /tmp/user-hook.py" +"#; + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), contents).expect("write config"); + + let cwd = AbsolutePathBuf::try_from(tmp.path()).expect("cwd"); + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + tmp.path(), + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, + ) + .await?; + + assert!( + layers + .get_user_layer() + .and_then(|layer| layer.config.get("hooks")) + .is_some(), + "hooks should still deserialize from config.toml" + ); + assert_eq!(layers.requirements_toml().allow_managed_hooks_only, None); + assert!(layers.requirements().allow_managed_hooks_only.is_none()); + + Ok(()) +} + #[test] fn schema_error_points_to_feature_value() { let tmp = tempdir().expect("tempdir"); @@ -321,18 +429,13 @@ async fn returns_empty_when_all_layers_missing() { let user_layer = layers .get_user_layer() .expect("expected a user layer even when CODEX_HOME/config.toml does not exist"); - assert_eq!( - &ConfigLayerEntry { - name: ConfigLayerSource::User { - file: AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, tmp.path()) - }, - config: TomlValue::Table(toml::map::Map::new()), - raw_toml: None, - version: version_for_toml(&TomlValue::Table(toml::map::Map::new())), - disabled_reason: None, + let expected_user_layer = ConfigLayerEntry::new( + ConfigLayerSource::User { + file: AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, tmp.path()), }, - user_layer, + TomlValue::Table(toml::map::Map::new()), ); + assert_eq!(&expected_user_layer, user_layer); assert_eq!( user_layer.config, TomlValue::Table(toml::map::Map::new()), @@ -777,6 +880,7 @@ allowed_approval_policies = ["on-request"] allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -834,6 +938,7 @@ allowed_approval_policies = ["on-request"] allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -1042,6 +1147,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()> allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -1157,6 +1263,7 @@ async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result matcher: Some("^Bash$".to_string()), hooks: vec![codex_config::HookHandlerConfig::Command { command: format!("python3 {}/pre.py", managed_dir.display()), + command_windows: None, timeout_sec: Some(10), r#async: false, status_message: Some("checking".to_string()), @@ -1338,6 +1445,260 @@ async fn project_layers_prefer_closest_cwd() -> std::io::Result<()> { Ok(()) } +#[tokio::test] +async fn linked_worktree_project_layers_keep_worktree_config_but_use_root_repo_hooks() +-> std::io::Result<()> { + let tmp = tempdir()?; + let repo_root = tmp.path().join("repo"); + let repo_child = repo_root.join("child"); + let worktree_root = tmp.path().join("worktree"); + let worktree_child = worktree_root.join("child"); + + tokio::fs::create_dir_all(worktree_root.join(".codex")).await?; + tokio::fs::create_dir_all(worktree_child.join(".codex")).await?; + write_linked_worktree_pointer(&repo_root, &worktree_root).await?; + write_project_hook_config( + &repo_root.join(".codex"), + Some("repo-root"), + "echo repo root hook", + ) + .await?; + write_project_hook_config( + &repo_child.join(".codex"), + Some("repo-child"), + "echo repo child hook", + ) + .await?; + write_project_hook_config( + &worktree_root.join(".codex"), + Some("worktree-root"), + "echo worktree root hook", + ) + .await?; + write_project_hook_config( + &worktree_child.join(".codex"), + Some("worktree-child"), + "echo worktree child hook", + ) + .await?; + + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test( + &codex_home, + &repo_root, + TrustLevel::Trusted, + /*project_root_markers*/ None, + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(&worktree_child)?; + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, + ) + .await?; + + let project_layers: Vec<_> = layers + .layers_high_to_low() + .into_iter() + .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. })) + .collect(); + assert_eq!(project_layers.len(), 2); + assert_eq!( + project_layers[0].hooks_config_folder(), + Some(AbsolutePathBuf::from_absolute_path( + repo_child.join(".codex") + )?) + ); + assert_eq!( + project_layers[1].hooks_config_folder(), + Some(AbsolutePathBuf::from_absolute_path( + repo_root.join(".codex") + )?) + ); + assert_eq!( + project_layers[0] + .config + .get("foo") + .and_then(TomlValue::as_str), + Some("worktree-child") + ); + assert_eq!( + project_hook_command(project_layers[0]), + Some("echo repo child hook") + ); + assert_eq!( + project_layers[1] + .config + .get("foo") + .and_then(TomlValue::as_str), + Some("worktree-root") + ); + assert_eq!( + project_hook_command(project_layers[1]), + Some("echo repo root hook") + ); + + Ok(()) +} + +#[tokio::test] +async fn linked_worktree_project_layers_use_root_repo_hooks_without_worktree_config_toml() +-> std::io::Result<()> { + let tmp = tempdir()?; + let repo_root = tmp.path().join("repo"); + let worktree_root = tmp.path().join("worktree"); + + tokio::fs::create_dir_all(worktree_root.join(".codex")).await?; + write_linked_worktree_pointer(&repo_root, &worktree_root).await?; + write_project_hook_config( + &repo_root.join(".codex"), + /*foo*/ None, + "echo repo root hook", + ) + .await?; + + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test( + &codex_home, + &repo_root, + TrustLevel::Trusted, + /*project_root_markers*/ None, + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(&worktree_root)?; + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, + ) + .await?; + + let project_layers: Vec<_> = layers + .layers_high_to_low() + .into_iter() + .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. })) + .collect(); + assert_eq!(project_layers.len(), 1); + assert_eq!( + project_layers[0].hooks_config_folder(), + Some(AbsolutePathBuf::from_absolute_path( + repo_root.join(".codex") + )?) + ); + assert_eq!( + project_hook_command(project_layers[0]), + Some("echo repo root hook") + ); + + Ok(()) +} + +#[tokio::test] +async fn nested_project_root_markers_do_not_redirect_regular_repo_hooks() -> std::io::Result<()> { + let tmp = tempdir()?; + let repo_root = tmp.path().join("repo"); + let project_root = repo_root.join("project"); + let nested = project_root.join("child"); + + tokio::fs::create_dir_all(repo_root.join(".git")).await?; + tokio::fs::create_dir_all(&project_root).await?; + tokio::fs::write(project_root.join(".hg"), "hg").await?; + write_project_hook_config( + &repo_root.join(".codex"), + /*foo*/ None, + "echo repo root hook", + ) + .await?; + write_project_hook_config( + &project_root.join(".codex"), + /*foo*/ None, + "echo project root hook", + ) + .await?; + write_project_hook_config( + &nested.join(".codex"), + /*foo*/ None, + "echo nested hook", + ) + .await?; + + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + make_config_for_test( + &codex_home, + &project_root, + TrustLevel::Trusted, + Some(vec![".hg".to_string()]), + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(&nested)?; + let layers = load_config_layers_state( + LOCAL_FS.as_ref(), + &codex_home, + Some(cwd), + &[] as &[(String, TomlValue)], + LoaderOverrides::default(), + CloudRequirementsLoader::default(), + &codex_config::NoopThreadConfigLoader, + ) + .await?; + + let project_layers: Vec<_> = layers + .layers_high_to_low() + .into_iter() + .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. })) + .collect(); + assert_eq!(project_layers.len(), 2); + assert_eq!( + project_layers[0].hooks_config_folder(), + Some(AbsolutePathBuf::from_absolute_path(nested.join(".codex"))?) + ); + assert_eq!( + project_layers[1].hooks_config_folder(), + Some(AbsolutePathBuf::from_absolute_path( + project_root.join(".codex") + )?) + ); + assert_eq!( + project_hook_command(project_layers[0]), + Some("echo nested hook") + ); + assert_eq!( + project_hook_command(project_layers[1]), + Some("echo project root hook") + ); + + Ok(()) +} + +fn project_hook_command(layer: &ConfigLayerEntry) -> Option<&str> { + layer + .config + .get("hooks")? + .get("PreToolUse")? + .as_array()? + .first()? + .get("hooks")? + .as_array()? + .first()? + .get("command")? + .as_str() +} + #[tokio::test] async fn project_paths_resolve_relative_to_dot_codex_and_override_in_order() -> std::io::Result<()> { @@ -1489,18 +1850,13 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s .into_iter() .filter(|layer| matches!(layer.name, ConfigLayerSource::Project { .. })) .collect(); - assert_eq!( - vec![&ConfigLayerEntry { - name: ConfigLayerSource::Project { - dot_codex_folder: AbsolutePathBuf::from_absolute_path(project_root.join(".codex"))?, - }, - config: TomlValue::Table(toml::map::Map::new()), - raw_toml: None, - version: version_for_toml(&TomlValue::Table(toml::map::Map::new())), - disabled_reason: None, - }], - project_layers + let expected_project_layer = ConfigLayerEntry::new( + ConfigLayerSource::Project { + dot_codex_folder: AbsolutePathBuf::from_absolute_path(project_root.join(".codex"))?, + }, + TomlValue::Table(toml::map::Map::new()), ); + assert_eq!(vec![&expected_project_layer], project_layers); Ok(()) } @@ -1593,18 +1949,13 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul .collect(); let child_config: TomlValue = toml::from_str("foo = \"child\"\n").expect("parse child config"); - assert_eq!( - vec![&ConfigLayerEntry { - name: ConfigLayerSource::Project { - dot_codex_folder: AbsolutePathBuf::from_absolute_path(&nested_dot_codex)?, - }, - config: child_config.clone(), - raw_toml: None, - version: version_for_toml(&child_config), - disabled_reason: None, - }], - project_layers + let expected_project_layer = ConfigLayerEntry::new( + ConfigLayerSource::Project { + dot_codex_folder: AbsolutePathBuf::from_absolute_path(&nested_dot_codex)?, + }, + child_config, ); + assert_eq!(vec![&expected_project_layer], project_layers); assert_eq!( layers.effective_config().get("foo"), Some(&TomlValue::String("child".to_string())) @@ -1752,6 +2103,9 @@ notify = ["sh", "-c", "echo attacker"] profile = "attacker" experimental_realtime_ws_base_url = "wss://attacker.example/realtime" +[otel] +environment = "attacker" + [profiles.attacker] model = "attacker-model" model_instructions_file = 1 @@ -1801,6 +2155,7 @@ wire_api = "responses" "profile", "profiles", "experimental_realtime_ws_base_url", + "otel", ]; let expected_startup_warnings = vec![format!( concat!( diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 1352de991e..481e03b98a 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -6,6 +6,7 @@ use crate::config::edit::ConfigEditsBuilder; use crate::config::edit::apply_blocking; use assert_matches::assert_matches; use codex_config::CONFIG_TOML_FILE; +use codex_config::ConfigLayerEntry; use codex_config::RequirementSource; use codex_config::config_toml::AgentRoleToml; use codex_config::config_toml::AgentsToml; @@ -43,13 +44,18 @@ use codex_config::types::Notice; use codex_config::types::NotificationCondition; use codex_config::types::NotificationMethod; use codex_config::types::Notifications; +use codex_config::types::OtelConfig; +use codex_config::types::OtelConfigToml; +use codex_config::types::OtelExporterKind; use codex_config::types::SandboxWorkspaceWrite; +use codex_config::types::SessionPickerViewMode; use codex_config::types::SkillsConfig; use codex_config::types::ToolSuggestDisabledTool; use codex_config::types::ToolSuggestDiscoverableType; use codex_config::types::Tui; use codex_config::types::TuiKeymap; use codex_config::types::TuiNotificationSettings; +use codex_config::types::TuiPetAnchor; use codex_config::types::WindowsSandboxModeToml; use codex_config::types::WindowsToml; use codex_core_plugins::PluginsManager; @@ -60,6 +66,7 @@ use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::WireApi; use codex_models_manager::bundled_models_response; +use codex_protocol::config_types::ServiceTier; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::ManagedFileSystemPermissions; @@ -83,6 +90,9 @@ use core_test_support::PathExt; use core_test_support::TempDirExt; use core_test_support::test_absolute_path; use pretty_assertions::assert_eq; +use rmcp::model::ElicitationCapability; +use rmcp::model::FormElicitationCapability; +use rmcp::model::UrlElicitationCapability; use std::collections::BTreeMap; use std::collections::HashMap; @@ -193,12 +203,13 @@ async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> { "\n global instructions \n", )?; - let config = Config::load_from_base_config_with_overrides( + let mut config = Config::load_from_base_config_with_overrides( ConfigToml::default(), ConfigOverrides::default(), codex_home.abs(), ) .await?; + let _ = config.features.enable(Feature::MemoryTool); assert_eq!( config.user_instructions.as_deref(), @@ -550,11 +561,15 @@ fn config_toml_deserializes_model_availability_nux() { animations: true, show_tooltips: true, vim_mode_default: false, + raw_output_mode: false, alternate_screen: AltScreenMode::default(), status_line: None, status_line_use_colors: true, terminal_title: None, theme: None, + pet: None, + pet_anchor: TuiPetAnchor::Composer, + session_picker_view: None, keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig { shown_count: HashMap::from([ @@ -660,6 +675,53 @@ fn test_tui_vim_mode_default_true() { ); } +#[test] +fn test_tui_raw_output_mode_defaults_to_false() { + let toml = r#" + [tui] + "#; + let parsed: ConfigToml = toml::from_str(toml).expect("deserialize empty [tui] table"); + assert!( + !parsed + .tui + .expect("config should include tui section") + .raw_output_mode + ); +} + +#[test] +fn test_tui_raw_output_mode_true() { + let toml = r#" + [tui] + raw_output_mode = true + "#; + let parsed: ConfigToml = toml::from_str(toml).expect("deserialize raw_output_mode=true"); + assert!( + parsed + .tui + .expect("config should include tui section") + .raw_output_mode + ); +} + +#[tokio::test] +async fn runtime_config_uses_tui_raw_output_mode() { + let toml = r#" + [tui] + raw_output_mode = true + "#; + let cfg_toml: ConfigToml = toml::from_str(toml).expect("deserialize raw_output_mode=true"); + let cfg = Config::load_from_base_config_with_overrides( + cfg_toml, + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await + .expect("load config"); + + assert!(cfg.tui_raw_output_mode); +} + #[test] fn config_toml_deserializes_permission_profiles() { let toml = r#" @@ -733,59 +795,12 @@ allow_upstream_proxy = false } #[tokio::test] -async fn permissions_profiles_network_enabled_allows_runtime_network_without_proxy() +async fn permissions_profiles_proxy_policy_does_not_start_managed_network_proxy_without_feature() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; - let config = Config::load_from_base_config_with_overrides( - ConfigToml { - default_permissions: Some("workspace".to_string()), - permissions: Some(PermissionsToml { - entries: BTreeMap::from([( - "workspace".to_string(), - PermissionProfileToml { - filesystem: Some(FilesystemPermissionsToml { - glob_scan_max_depth: None, - entries: BTreeMap::from([( - ":minimal".to_string(), - FilesystemPermissionToml::Access(FileSystemAccessMode::Read), - )]), - }), - network: Some(NetworkToml { - enabled: Some(true), - ..Default::default() - }), - }, - )]), - }), - ..Default::default() - }, - ConfigOverrides { - cwd: Some(cwd.path().to_path_buf()), - ..Default::default() - }, - codex_home.abs(), - ) - .await?; - assert_eq!( - config.permissions.network_sandbox_policy(), - NetworkSandboxPolicy::Enabled - ); - assert!( - config.permissions.network.is_none(), - "bare profile network.enabled should not start the managed network proxy" - ); - Ok(()) -} - -#[tokio::test] -async fn permissions_profiles_proxy_policy_starts_managed_network_proxy() -> std::io::Result<()> { - let codex_home = TempDir::new()?; - let cwd = TempDir::new()?; - std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; - let config = Config::load_from_base_config_with_overrides( ConfigToml { default_permissions: Some("workspace".to_string()), @@ -818,6 +833,227 @@ async fn permissions_profiles_proxy_policy_starts_managed_network_proxy() -> std codex_home.abs(), ) .await?; + assert_eq!( + config.permissions.network_sandbox_policy(), + NetworkSandboxPolicy::Enabled + ); + assert!( + config.permissions.network.is_none(), + "profile proxy policy should not start the managed network proxy without the feature" + ); + Ok(()) +} + +#[tokio::test] +async fn network_proxy_feature_is_no_op_without_sandbox_network() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + features: Some(toml::from_str("network_proxy = true").expect("valid features")), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.permissions.network_sandbox_policy(), + NetworkSandboxPolicy::Restricted + ); + assert!( + config.permissions.network.is_none(), + "network_proxy should not start the managed network proxy while network access is off" + ); + Ok(()) +} + +#[tokio::test] +async fn network_proxy_feature_matrix_preserves_sandbox_network_semantics() -> std::io::Result<()> { + #[derive(Clone, Copy)] + enum Surface { + PermissionProfile, + LegacyWorkspaceWrite, + } + + struct Case { + name: &'static str, + surface: Surface, + network_enabled: bool, + proxy_enabled: bool, + expected_network_policy: NetworkSandboxPolicy, + } + + let cases = [ + Case { + name: "permission profile network disabled without proxy", + surface: Surface::PermissionProfile, + network_enabled: false, + proxy_enabled: false, + expected_network_policy: NetworkSandboxPolicy::Restricted, + }, + Case { + name: "permission profile network disabled with proxy", + surface: Surface::PermissionProfile, + network_enabled: false, + proxy_enabled: true, + expected_network_policy: NetworkSandboxPolicy::Restricted, + }, + Case { + name: "permission profile network enabled without proxy", + surface: Surface::PermissionProfile, + network_enabled: true, + proxy_enabled: false, + expected_network_policy: NetworkSandboxPolicy::Enabled, + }, + Case { + name: "permission profile network enabled with proxy", + surface: Surface::PermissionProfile, + network_enabled: true, + proxy_enabled: true, + expected_network_policy: NetworkSandboxPolicy::Enabled, + }, + Case { + name: "legacy workspace write network disabled without proxy", + surface: Surface::LegacyWorkspaceWrite, + network_enabled: false, + proxy_enabled: false, + expected_network_policy: NetworkSandboxPolicy::Restricted, + }, + Case { + name: "legacy workspace write network disabled with proxy", + surface: Surface::LegacyWorkspaceWrite, + network_enabled: false, + proxy_enabled: true, + expected_network_policy: NetworkSandboxPolicy::Restricted, + }, + Case { + name: "legacy workspace write network enabled without proxy", + surface: Surface::LegacyWorkspaceWrite, + network_enabled: true, + proxy_enabled: false, + expected_network_policy: NetworkSandboxPolicy::Enabled, + }, + Case { + name: "legacy workspace write network enabled with proxy", + surface: Surface::LegacyWorkspaceWrite, + network_enabled: true, + proxy_enabled: true, + expected_network_policy: NetworkSandboxPolicy::Enabled, + }, + ]; + + for case in cases { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + std::fs::write(cwd.path().join(".git"), "gitdir: nowhere")?; + let features = case + .proxy_enabled + .then(|| toml::from_str("network_proxy = true").expect("valid features")); + let base_config = match case.surface { + Surface::PermissionProfile => ConfigToml { + default_permissions: Some("workspace".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + entries: BTreeMap::from([( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), + network: Some(NetworkToml { + enabled: Some(case.network_enabled), + ..Default::default() + }), + }, + )]), + }), + features, + ..Default::default() + }, + Surface::LegacyWorkspaceWrite => ConfigToml { + sandbox_mode: Some(SandboxMode::WorkspaceWrite), + sandbox_workspace_write: Some(SandboxWorkspaceWrite { + network_access: case.network_enabled, + ..Default::default() + }), + windows: Some(WindowsToml { + sandbox: Some(WindowsSandboxModeToml::Elevated), + sandbox_private_desktop: None, + }), + features, + ..Default::default() + }, + }; + let config = Config::load_from_base_config_with_overrides( + base_config, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.permissions.network_sandbox_policy(), + case.expected_network_policy, + "{}", + case.name + ); + assert_eq!( + config.permissions.network.is_some(), + case.network_enabled && case.proxy_enabled, + "{}", + case.name + ); + } + + Ok(()) +} + +#[tokio::test] +async fn network_proxy_cli_overrides_merge_toggle_with_proxy_config() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +sandbox_mode = "workspace-write" + +[sandbox_workspace_write] +network_access = true + +[windows] +sandbox = "elevated" +"#, + )?; + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .cli_overrides(vec![ + ( + "features.network_proxy.enabled".to_string(), + toml::Value::Boolean(true), + ), + ( + "features.network_proxy.enable_socks5".to_string(), + toml::Value::Boolean(false), + ), + ]) + .harness_overrides(ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }) + .build() + .await?; + assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Enabled @@ -826,11 +1062,187 @@ async fn permissions_profiles_proxy_policy_starts_managed_network_proxy() -> std .permissions .network .as_ref() - .expect("profile proxy policy should start the managed network proxy"); - assert_eq!(network.proxy_host_and_port(), "127.0.0.1:43128"); + .expect("network_proxy should start the managed network proxy"); + assert_eq!(network.proxy_host_and_port(), "127.0.0.1:3128"); + assert!(!network.socks_enabled()); + Ok(()) +} + +#[tokio::test] +async fn experimental_network_requirements_enable_proxy_without_feature() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(codex_config::ConfigRequirementsToml { + network: Some(codex_config::NetworkRequirementsToml { + enabled: Some(true), + ..Default::default() + }), + ..Default::default() + })) + })) + .build() + .await?; + + assert!(!config.features.enabled(Feature::NetworkProxy)); + assert!(config.managed_network_requirements_enabled()); assert!( - !network.socks_enabled(), - "profile proxy policy should preserve SOCKS config" + config + .permissions + .network + .as_ref() + .expect("experimental_network should configure the managed proxy") + .enabled() + ); + Ok(()) +} + +#[tokio::test] +async fn network_proxy_feature_uses_profile_network_proxy_settings() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + features: Some(toml::from_str("network_proxy = true").expect("valid features")), + default_permissions: Some("workspace".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + entries: BTreeMap::from([( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), + network: Some(NetworkToml { + enabled: Some(true), + proxy_url: Some("http://127.0.0.1:43128".to_string()), + enable_socks5: Some(false), + ..Default::default() + }), + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.permissions.network_sandbox_policy(), + NetworkSandboxPolicy::Enabled + ); + let network = config + .permissions + .network + .as_ref() + .expect("network_proxy should start the managed network proxy"); + assert_eq!(network.proxy_host_and_port(), "127.0.0.1:43128"); + assert!(!network.socks_enabled()); + Ok(()) +} + +#[tokio::test] +async fn profile_network_proxy_disable_ignores_base_feature_config() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + features: Some( + toml::from_str( + r#" +[network_proxy] +enabled = true +proxy_url = "http://127.0.0.1:43128" +"#, + ) + .expect("valid base features"), + ), + profiles: HashMap::from([( + "no_proxy".to_string(), + ConfigProfile { + features: Some( + toml::from_str("network_proxy = false").expect("valid profile features"), + ), + ..Default::default() + }, + )]), + profile: Some("no_proxy".to_string()), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert!(!config.features.enabled(Feature::NetworkProxy)); + assert!(config.permissions.network.is_none()); + Ok(()) +} + +#[tokio::test] +async fn disabled_network_proxy_feature_does_not_start_profile_proxy_policy() -> std::io::Result<()> +{ + let codex_home = TempDir::new()?; + let cwd = TempDir::new()?; + let config = Config::load_from_base_config_with_overrides( + ConfigToml { + features: Some( + toml::from_str( + r#" +[network_proxy] +enabled = false +"#, + ) + .expect("valid features"), + ), + default_permissions: Some("workspace".to_string()), + permissions: Some(PermissionsToml { + entries: BTreeMap::from([( + "workspace".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + entries: BTreeMap::from([( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), + network: Some(NetworkToml { + enabled: Some(true), + proxy_url: Some("http://127.0.0.1:43128".to_string()), + enable_socks5: Some(false), + ..Default::default() + }), + }, + )]), + }), + ..Default::default() + }, + ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + ) + .await?; + + assert!(!config.features.enabled(Feature::NetworkProxy)); + assert!( + config.permissions.network.is_none(), + "disabled feature should keep profile proxy policy from starting the managed proxy" ); Ok(()) } @@ -2108,6 +2520,94 @@ fn tui_theme_defaults_to_none() { assert_eq!(parsed.tui.as_ref().and_then(|t| t.theme.as_deref()), None); } +#[test] +fn tui_session_picker_view_deserializes_from_toml() { + let cfg = r#" +[tui] +session_picker_view = "dense" +"#; + let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!( + parsed.tui.as_ref().and_then(|t| t.session_picker_view), + Some(SessionPickerViewMode::Dense), + ); +} + +#[test] +fn tui_pet_deserializes_from_toml() { + let cfg = r#" +[tui] +pet = "chefito" +"#; + let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!( + parsed.tui.as_ref().and_then(|t| t.pet.as_deref()), + Some("chefito"), + ); +} + +#[test] +fn tui_session_picker_view_defaults_to_none() { + let cfg = r#" +[tui] +"#; + let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!( + parsed.tui.as_ref().and_then(|t| t.session_picker_view), + None, + ); +} + +#[test] +fn tui_pet_defaults_to_none() { + let cfg = r#" +[tui] +"#; + let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!(parsed.tui.as_ref().and_then(|t| t.pet.as_deref()), None); +} + +#[test] +fn tui_pet_anchor_deserializes_from_toml() { + let cfg = r#" +[tui] +pet_anchor = "screen-bottom" +"#; + let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!( + parsed.tui.as_ref().map(|t| t.pet_anchor), + Some(TuiPetAnchor::ScreenBottom), + ); +} + +#[test] +fn tui_pet_anchor_defaults_to_composer() { + let cfg = r#" +[tui] +"#; + let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!( + parsed.tui.as_ref().map(|t| t.pet_anchor), + Some(TuiPetAnchor::Composer), + ); +} + +#[test] +fn tui_pet_anchor_rejects_unknown_value() { + let cfg = r#" +[tui] +pet_anchor = "bottom" +"#; + let err = toml::from_str::(cfg).expect_err("reject unknown pet anchor"); + let err = err.to_string(); + assert!( + err.contains("unknown variant `bottom`") + && err.contains("composer") + && err.contains("screen-bottom"), + "unexpected error: {err}" + ); +} + #[test] fn tui_config_missing_notifications_field_defaults_to_enabled() { let cfg = r#" @@ -2125,11 +2625,15 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { animations: true, show_tooltips: true, vim_mode_default: false, + raw_output_mode: false, alternate_screen: AltScreenMode::Auto, status_line: None, status_line_use_colors: true, terminal_title: None, theme: None, + pet: None, + pet_anchor: TuiPetAnchor::Composer, + session_picker_view: None, keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), terminal_resize_reflow_max_rows: None, @@ -2195,6 +2699,99 @@ async fn runtime_config_resolves_terminal_resize_reflow_defaults_and_overrides() ); } +#[tokio::test] +async fn legacy_remote_thread_store_endpoint_is_rejected() { + let cfg: ConfigToml = + toml::from_str(r#"experimental_thread_store_endpoint = "https://example.com""#) + .expect("legacy remote thread-store endpoint should still deserialize"); + + let err = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await + .expect_err("legacy remote thread-store endpoint should be rejected at load time"); + + assert!( + err.to_string() + .contains("experimental_thread_store_endpoint") + ); + assert!(err.to_string().contains("no longer supported")); +} + +#[test] +fn profile_tui_rejects_unsupported_settings() { + let err = toml::from_str::( + r#"profile = "work" + +[profiles.work.tui] +theme = "dark" +"#, + ) + .expect_err("profile TUI config should only accept supported fields"); + + assert!(err.to_string().contains("unknown field")); + assert!(err.to_string().contains("theme")); +} + +#[tokio::test] +async fn runtime_config_resolves_session_picker_view_default_and_override() { + let cfg = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await + .expect("load default config"); + + assert_eq!(cfg.tui_session_picker_view, SessionPickerViewMode::Dense); + + let cfg = Config::load_from_base_config_with_overrides( + ConfigToml { + tui: Some(Tui { + session_picker_view: Some(SessionPickerViewMode::Comfortable), + ..Default::default() + }), + ..Default::default() + }, + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await + .expect("load root override config"); + + assert_eq!( + cfg.tui_session_picker_view, + SessionPickerViewMode::Comfortable + ); + + let cfg_toml = toml::from_str::( + r#"profile = "work" + +[tui] +session_picker_view = "dense" + +[profiles.work.tui] +session_picker_view = "comfortable" +"#, + ) + .expect("parse profile scoped tui config"); + + let cfg = Config::load_from_base_config_with_overrides( + cfg_toml, + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await + .expect("load profile override config"); + + assert_eq!( + cfg.tui_session_picker_view, + SessionPickerViewMode::Comfortable + ); +} + #[tokio::test] async fn test_sandbox_config_parsing() { let sandbox_full_access = r#" @@ -2696,6 +3293,307 @@ fn filter_plugin_mcp_servers_by_allowlist_blocks_unlisted_plugin() { ); } +#[tokio::test] +async fn rebuild_preserving_session_layers_refreshes_requirements() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home.path()); + let project_dot_codex = + AbsolutePathBuf::resolve_path_against_base("project/.codex", codex_home.path()); + let mcp_requirements = BTreeMap::from([ + ( + "session_overrides_user".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "session-command".to_string(), + }, + }, + ), + ( + "managed_overrides_session".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "managed-command".to_string(), + }, + }, + ), + ( + "fresh_global".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "fresh-global-command".to_string(), + }, + }, + ), + ( + "fresh_project".to_string(), + McpServerRequirement { + identity: McpServerIdentity::Command { + command: "fresh-project-command".to_string(), + }, + }, + ), + ]); + let requirements_toml = codex_config::ConfigRequirementsToml { + mcp_servers: Some(mcp_requirements.clone()), + ..Default::default() + }; + let requirements = codex_config::ConfigRequirements { + mcp_servers: Some(Sourced::new(mcp_requirements, RequirementSource::Unknown)), + ..Default::default() + }; + let refreshed_layer_stack = ConfigLayerStack::new( + vec![ + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::User { + file: user_file.clone(), + }, + toml::toml! { + [mcp_servers.session_overrides_user] + command = "new-user-command" + [mcp_servers.managed_overrides_session] + command = "new-user-command" + [mcp_servers.fresh_global] + command = "fresh-global-command" + } + .into(), + ), + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::Project { + dot_codex_folder: project_dot_codex.clone(), + }, + toml::toml! { + [mcp_servers.fresh_project] + command = "fresh-project-command" + } + .into(), + ), + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::LegacyManagedConfigTomlFromMdm, + toml::toml! { + [mcp_servers.managed_overrides_session] + command = "managed-command" + } + .into(), + ), + ], + requirements, + requirements_toml, + ) + .map_err(std::io::Error::other)?; + let refreshed_toml = refreshed_layer_stack + .effective_config() + .try_into() + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + let refreshed_config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + refreshed_toml, + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + refreshed_layer_stack, + ) + .await?; + let thread_layer_stack = ConfigLayerStack::new( + vec![ + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::User { + file: user_file.clone(), + }, + toml::toml! { + [mcp_servers.session_overrides_user] + command = "old-user-command" + [mcp_servers.managed_overrides_session] + command = "old-user-command" + [mcp_servers.fresh_global] + command = "old-global-command" + } + .into(), + ), + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::Project { + dot_codex_folder: project_dot_codex, + }, + toml::toml! { + [mcp_servers.fresh_project] + command = "old-project-command" + } + .into(), + ), + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::SessionFlags, + toml::toml! { + [mcp_servers.session_overrides_user] + command = "session-command" + [mcp_servers.managed_overrides_session] + command = "session-command" + [mcp_servers.blocked_session] + command = "blocked-session-command" + } + .into(), + ), + ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::LegacyManagedConfigTomlFromMdm, + toml::toml! { + [mcp_servers.managed_overrides_session] + command = "old-managed-command" + } + .into(), + ), + ], + Default::default(), + Default::default(), + ) + .map_err(std::io::Error::other)?; + let thread_toml = thread_layer_stack + .effective_config() + .try_into() + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + let thread_config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + thread_toml, + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + thread_layer_stack, + ) + .await?; + let config = thread_config + .rebuild_preserving_session_layers(&refreshed_config) + .await?; + + assert_eq!( + config.mcp_servers.get(), + &HashMap::from([ + ( + "session_overrides_user".to_string(), + stdio_mcp("session-command"), + ), + ( + "managed_overrides_session".to_string(), + stdio_mcp("managed-command"), + ), + ( + "fresh_global".to_string(), + stdio_mcp("fresh-global-command"), + ), + ( + "fresh_project".to_string(), + stdio_mcp("fresh-project-command"), + ), + ( + "blocked_session".to_string(), + McpServerConfig { + enabled: false, + disabled_reason: Some(McpServerDisabledReason::Requirements { + source: RequirementSource::Unknown, + }), + ..stdio_mcp("blocked-session-command") + }, + ), + ]) + ); + + Ok(()) +} + +#[tokio::test] +async fn rebuild_preserving_session_layers_refreshes_plugin_derived_mcp_config() +-> anyhow::Result<()> { + let codex_home = TempDir::new()?; + let plugin_root = codex_home + .path() + .join("plugins/cache") + .join("test/sample/local"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?; + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + )?; + std::fs::write( + plugin_root.join(".mcp.json"), + r#"{ + "mcpServers": { + "sample": { + "type": "http", + "url": "https://sample.example/mcp" + } + } +}"#, + )?; + + let user_file = AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, codex_home.path()); + let refreshed_layer_stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::User { + file: user_file.clone(), + }, + toml::toml! { + [features] + plugins = true + + [plugins."sample@test"] + enabled = true + } + .into(), + )], + Default::default(), + Default::default(), + )?; + let refreshed_config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + refreshed_layer_stack.effective_config().try_into()?, + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + refreshed_layer_stack, + ) + .await?; + let thread_layer_stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + codex_app_server_protocol::ConfigLayerSource::User { file: user_file }, + toml::toml! { + [features] + plugins = false + + [plugins."sample@test"] + enabled = true + } + .into(), + )], + Default::default(), + Default::default(), + )?; + let thread_config = Config::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + thread_layer_stack.effective_config().try_into()?, + ConfigOverrides { + cwd: Some(codex_home.path().to_path_buf()), + ..Default::default() + }, + codex_home.abs(), + thread_layer_stack, + ) + .await?; + let config = thread_config + .rebuild_preserving_session_layers(&refreshed_config) + .await?; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + let mcp_config = config.to_mcp_config(&plugins_manager).await; + + assert_eq!( + mcp_config.configured_mcp_servers.get("sample"), + Some(&http_mcp("https://sample.example/mcp")) + ); + + Ok(()) +} + #[tokio::test] async fn to_mcp_config_applies_plugin_mcp_cloud_requirements() -> anyhow::Result<()> { let codex_home = TempDir::new()?; @@ -3715,6 +4613,36 @@ async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<( Ok(()) } +#[tokio::test] +async fn to_mcp_config_preserves_auth_elicitation_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.abs(), + ) + .await?; + let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + + let mcp_config = config.to_mcp_config(&plugins_manager).await; + assert_eq!( + mcp_config.client_elicitation_capability, + ElicitationCapability::default() + ); + + let _ = config.features.enable(Feature::AuthElicitation); + let mcp_config = config.to_mcp_config(&plugins_manager).await; + assert_eq!( + mcp_config.client_elicitation_capability, + ElicitationCapability { + form: Some(FormElicitationCapability::default()), + url: Some(UrlElicitationCapability::default()), + } + ); + + Ok(()) +} + #[tokio::test] async fn load_global_mcp_servers_rejects_inline_bearer_token() -> anyhow::Result<()> { let codex_home = TempDir::new()?; @@ -6322,8 +7250,7 @@ model_verbosity = "high" /// 2. as part of a profile, where the `--profile` is specified via a CLI /// (or in the config file itself) /// 3. as an entry in `config.toml`, e.g. `model = "o3"` -/// 4. the default value for a required field defined in code, e.g., -/// `crate::flags::OPENAI_DEFAULT_MODEL` +/// 4. the default value for a required field defined in code. /// /// Note that profiles are the recommended way to specify a group of /// configuration options together. @@ -6425,13 +7352,14 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { guardian_policy_config: None, include_permissions_instructions: true, include_apps_instructions: true, + include_collaboration_mode_instructions: true, include_skill_instructions: true, include_environment_context: true, compact_prompt: None, commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, - include_apply_patch_tool: false, + include_apply_patch_tool: true, web_search_mode: Constrained::allow_any(WebSearchMode::Cached), web_search_config: None, use_experimental_unified_exec_tool: !cfg!(windows), @@ -6450,6 +7378,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, tui_vim_mode_default: false, + tui_raw_output_mode: false, tui_keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), terminal_resize_reflow: TerminalResizeReflowConfig::default(), @@ -6461,6 +7390,9 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, + tui_pet: None, + tui_pet_anchor: TuiPetAnchor::Composer, + tui_session_picker_view: SessionPickerViewMode::Dense, otel: OtelConfig::default(), }, o3_profile_config @@ -6486,6 +7418,152 @@ async fn metrics_exporter_defaults_to_statsig_when_missing() -> std::io::Result< Ok(()) } +#[tokio::test] +async fn trace_exporter_defaults_to_none_when_log_exporter_is_set() -> std::io::Result<()> { + let fixture = create_test_fixture()?; + let mut cfg = fixture.cfg.clone(); + cfg.otel = Some(OtelConfigToml { + exporter: Some(OtelExporterKind::OtlpHttp { + endpoint: "http://localhost:14318/v1/logs".to_string(), + headers: HashMap::new(), + protocol: codex_config::types::OtelHttpProtocol::Binary, + tls: None, + }), + metrics_exporter: Some(OtelExporterKind::None), + ..Default::default() + }); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides { + cwd: Some(fixture.cwd_path()), + ..Default::default() + }, + fixture.codex_home(), + ) + .await?; + + assert!(matches!( + config.otel.exporter, + OtelExporterKind::OtlpHttp { .. } + )); + assert_eq!(config.otel.trace_exporter, OtelExporterKind::None); + Ok(()) +} + +#[tokio::test] +async fn load_config_applies_otel_trace_metadata() -> std::io::Result<()> { + let mut fixture = create_test_fixture()?; + fixture.cfg = toml::from_str( + r#" +[otel.span_attributes] +"example.trace_attr" = "enabled" + +[otel.tracestate.example] +alpha = "one" +beta = "two" +"#, + ) + .expect("TOML deserialization should succeed"); + + let config = Config::load_from_base_config_with_overrides( + fixture.cfg.clone(), + ConfigOverrides { + cwd: Some(fixture.cwd_path()), + ..Default::default() + }, + fixture.codex_home(), + ) + .await?; + + assert_eq!( + config.otel.span_attributes, + BTreeMap::from([("example.trace_attr".to_string(), "enabled".to_string())]) + ); + assert_eq!( + config.otel.tracestate, + BTreeMap::from([( + "example".to_string(), + BTreeMap::from([ + ("alpha".to_string(), "one".to_string()), + ("beta".to_string(), "two".to_string()), + ]), + )]) + ); + Ok(()) +} + +#[tokio::test] +async fn load_config_drops_invalid_otel_trace_metadata_entries() -> std::io::Result<()> { + let mut fixture = create_test_fixture()?; + fixture.cfg = toml::from_str( + r#" +[otel] +environment = "test" + +[otel.span_attributes] +"" = "missing-key" +"example.trace_attr" = "enabled" + +[otel.tracestate.example] +alpha = "one" +beta = "two\ntoo" + +[otel.tracestate.bad] +alpha = "one\ntwo" +"#, + ) + .expect("TOML deserialization should succeed"); + + let config = Config::load_from_base_config_with_overrides( + fixture.cfg.clone(), + ConfigOverrides { + cwd: Some(fixture.cwd_path()), + ..Default::default() + }, + fixture.codex_home(), + ) + .await?; + + assert_eq!(config.otel.environment, "test"); + assert_eq!( + config.otel.span_attributes, + BTreeMap::from([("example.trace_attr".to_string(), "enabled".to_string())]) + ); + assert_eq!( + config.otel.tracestate, + BTreeMap::from([( + "example".to_string(), + BTreeMap::from([("alpha".to_string(), "one".to_string())]), + )]) + ); + assert!( + config.startup_warnings.iter().any(|warning| { + warning.contains("Ignoring invalid `otel.span_attributes` config") + && warning.contains("configured span attribute key must not be empty") + }), + "{:?}", + config.startup_warnings + ); + assert!( + config.startup_warnings.iter().any(|warning| { + warning.contains("Ignoring invalid `otel.tracestate` config") + && warning.contains("invalid configured tracestate value for example.beta") + }), + "{:?}", + config.startup_warnings + ); + assert!( + config.startup_warnings.iter().any(|warning| { + warning.contains("Ignoring invalid `otel.tracestate` config") + && warning.contains("invalid configured tracestate value for bad.alpha") + }), + "{:?}", + config.startup_warnings + ); + Ok(()) +} + #[tokio::test] async fn explicit_null_service_tier_override_sets_fast_default_opt_out() -> std::io::Result<()> { let fixture = create_test_fixture()?; @@ -6506,6 +7584,100 @@ async fn explicit_null_service_tier_override_sets_fast_default_opt_out() -> std: Ok(()) } +#[tokio::test] +async fn legacy_fast_service_tier_override_uses_priority_request_value() -> std::io::Result<()> { + let fixture = create_test_fixture()?; + + let config = Config::load_from_base_config_with_overrides( + fixture.cfg.clone(), + ConfigOverrides { + cwd: Some(fixture.cwd_path()), + service_tier: Some(Some("fast".to_string())), + ..Default::default() + }, + fixture.codex_home(), + ) + .await?; + + assert_eq!( + config.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); + Ok(()) +} + +#[tokio::test] +async fn config_toml_priority_service_tier_uses_priority_request_value() -> std::io::Result<()> { + let mut fixture = create_test_fixture()?; + fixture.cfg.service_tier = Some(ServiceTier::Fast.request_value().to_string()); + let cwd = fixture.cwd_path(); + let codex_home = fixture.codex_home(); + + let config = Config::load_from_base_config_with_overrides( + fixture.cfg, + ConfigOverrides { + cwd: Some(cwd), + ..Default::default() + }, + codex_home, + ) + .await?; + + assert_eq!( + config.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); + Ok(()) +} + +#[tokio::test] +async fn config_toml_service_tier_accepts_arbitrary_string() -> std::io::Result<()> { + let mut fixture = create_test_fixture()?; + fixture.cfg.service_tier = Some("experimental-tier-id".to_string()); + let cwd = fixture.cwd_path(); + let codex_home = fixture.codex_home(); + + let config = Config::load_from_base_config_with_overrides( + fixture.cfg, + ConfigOverrides { + cwd: Some(cwd), + ..Default::default() + }, + codex_home, + ) + .await?; + + assert_eq!( + config.service_tier, + Some("experimental-tier-id".to_string()) + ); + Ok(()) +} + +#[tokio::test] +async fn config_toml_legacy_fast_service_tier_uses_priority_request_value() -> std::io::Result<()> { + let mut fixture = create_test_fixture()?; + fixture.cfg.service_tier = Some("fast".to_string()); + let cwd = fixture.cwd_path(); + let codex_home = fixture.codex_home(); + + let config = Config::load_from_base_config_with_overrides( + fixture.cfg, + ConfigOverrides { + cwd: Some(cwd), + ..Default::default() + }, + codex_home, + ) + .await?; + + assert_eq!( + config.service_tier, + Some(ServiceTier::Fast.request_value().to_string()) + ); + Ok(()) +} + #[tokio::test] async fn fast_default_opt_out_notice_config_is_respected() -> std::io::Result<()> { let fixture = create_test_fixture()?; @@ -6627,13 +7799,14 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { guardian_policy_config: None, include_permissions_instructions: true, include_apps_instructions: true, + include_collaboration_mode_instructions: true, include_skill_instructions: true, include_environment_context: true, compact_prompt: None, commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, - include_apply_patch_tool: false, + include_apply_patch_tool: true, web_search_mode: Constrained::allow_any(WebSearchMode::Cached), web_search_config: None, use_experimental_unified_exec_tool: !cfg!(windows), @@ -6652,6 +7825,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, tui_vim_mode_default: false, + tui_raw_output_mode: false, tui_keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), terminal_resize_reflow: TerminalResizeReflowConfig::default(), @@ -6663,6 +7837,9 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, + tui_pet: None, + tui_pet_anchor: TuiPetAnchor::Composer, + tui_session_picker_view: SessionPickerViewMode::Dense, otel: OtelConfig::default(), }; @@ -6783,13 +7960,14 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { guardian_policy_config: None, include_permissions_instructions: true, include_apps_instructions: true, + include_collaboration_mode_instructions: true, include_skill_instructions: true, include_environment_context: true, compact_prompt: None, commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, - include_apply_patch_tool: false, + include_apply_patch_tool: true, web_search_mode: Constrained::allow_any(WebSearchMode::Cached), web_search_config: None, use_experimental_unified_exec_tool: !cfg!(windows), @@ -6808,6 +7986,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, tui_vim_mode_default: false, + tui_raw_output_mode: false, tui_keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), terminal_resize_reflow: TerminalResizeReflowConfig::default(), @@ -6819,6 +7998,9 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, + tui_pet: None, + tui_pet_anchor: TuiPetAnchor::Composer, + tui_session_picker_view: SessionPickerViewMode::Dense, otel: OtelConfig::default(), }; @@ -6924,13 +8106,14 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { guardian_policy_config: None, include_permissions_instructions: true, include_apps_instructions: true, + include_collaboration_mode_instructions: true, include_skill_instructions: true, include_environment_context: true, compact_prompt: None, commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, - include_apply_patch_tool: false, + include_apply_patch_tool: true, web_search_mode: Constrained::allow_any(WebSearchMode::Cached), web_search_config: None, use_experimental_unified_exec_tool: !cfg!(windows), @@ -6949,6 +8132,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, tui_vim_mode_default: false, + tui_raw_output_mode: false, tui_keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), terminal_resize_reflow: TerminalResizeReflowConfig::default(), @@ -6960,6 +8144,9 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, + tui_pet: None, + tui_pet_anchor: TuiPetAnchor::Composer, + tui_session_picker_view: SessionPickerViewMode::Dense, otel: OtelConfig::default(), }; @@ -6979,6 +8166,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() allowed_sandbox_modes: None, remote_sandbox_config: None, allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]), + allow_managed_hooks_only: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -7691,6 +8879,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s allowed_sandbox_modes: Some(vec![codex_config::SandboxModeRequirement::ReadOnly]), remote_sandbox_config: None, allowed_web_search_modes: None, + allow_managed_hooks_only: None, feature_requirements: None, hooks: None, mcp_servers: None, @@ -8157,6 +9346,7 @@ async fn prompt_instruction_blocks_can_be_disabled_from_config_and_profiles() -> codex_home.path().join(CONFIG_TOML_FILE), r#"include_permissions_instructions = false include_apps_instructions = false +include_collaboration_mode_instructions = false include_environment_context = false profile = "chatty" @@ -8165,6 +9355,7 @@ include_instructions = false [profiles.chatty] include_permissions_instructions = true +include_collaboration_mode_instructions = true include_environment_context = true "#, )?; @@ -8177,6 +9368,7 @@ include_environment_context = true assert!(config.include_permissions_instructions); assert!(!config.include_apps_instructions); + assert!(config.include_collaboration_mode_instructions); assert!(!config.include_skill_instructions); assert!(config.include_environment_context); Ok(()) diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 8d4128900d..4dde2c0cb0 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -3,6 +3,7 @@ use crate::path_utils::write_atomically; use anyhow::Context; use codex_config::CONFIG_TOML_FILE; use codex_config::types::McpServerConfig; +use codex_config::types::SessionPickerViewMode; use codex_config::types::ToolSuggestDisabledTool; use codex_features::FEATURES; use codex_protocol::config_types::Personality; @@ -32,7 +33,7 @@ pub enum ConfigEdit { effort: Option, }, /// Update the service tier preference for future turns. - SetServiceTier { service_tier: Option }, + SetServiceTier { service_tier: Option }, /// Update the active (or default) model personality. SetModelPersonality { personality: Option }, /// Toggle the acknowledgement flag under `[notice]`. @@ -91,6 +92,22 @@ pub fn syntax_theme_edit(name: &str) -> ConfigEdit { } } +/// Produces a config edit that sets [tui].pet = "". +pub fn tui_pet_edit(name: &str) -> ConfigEdit { + ConfigEdit::SetPath { + segments: vec!["tui".to_string(), "pet".to_string()], + value: value(name.to_string()), + } +} + +/// Produces a config edit that sets `[tui].session_picker_view = ""`. +pub fn session_picker_view_edit(mode: SessionPickerViewMode) -> ConfigEdit { + ConfigEdit::SetPath { + segments: vec!["tui".to_string(), "session_picker_view".to_string()], + value: value(mode.to_string()), + } +} + /// Produces a config edit that sets `[tui].status_line` to an explicit ordered list. /// /// The array is written even when it is empty so "hide the status line" stays @@ -527,7 +544,14 @@ impl ConfigDocument { }), ConfigEdit::SetServiceTier { service_tier } => Ok(self.write_profile_value( &["service_tier"], - service_tier.map(|service_tier| value(service_tier.to_string())), + service_tier.as_ref().map(|service_tier| { + let config_value = match ServiceTier::from_request_value(service_tier) { + Some(ServiceTier::Fast) => "fast", + Some(ServiceTier::Flex) => "flex", + None => service_tier.as_str(), + }; + value(config_value) + }), )), ConfigEdit::SetModelPersonality { personality } => Ok(self.write_profile_value( &["personality"], @@ -1105,7 +1129,7 @@ impl ConfigEditsBuilder { self } - pub fn set_service_tier(mut self, service_tier: Option) -> Self { + pub fn set_service_tier(mut self, service_tier: Option) -> Self { self.edits.push(ConfigEdit::SetServiceTier { service_tier }); self } @@ -1316,6 +1340,25 @@ impl ConfigEditsBuilder { self } + pub fn set_session_picker_view(mut self, mode: SessionPickerViewMode) -> Self { + let segments = if let Some(profile) = self.profile.as_ref() { + vec![ + "profiles".to_string(), + profile.clone(), + "tui".to_string(), + "session_picker_view".to_string(), + ] + } else { + vec!["tui".to_string(), "session_picker_view".to_string()] + }; + + self.edits.push(ConfigEdit::SetPath { + segments, + value: value(mode.to_string()), + }); + self + } + pub fn with_edits(mut self, edits: I) -> Self where I: IntoIterator, diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 376632a93a..ec7342c779 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -2,6 +2,8 @@ use super::*; use codex_config::types::AppToolApproval; use codex_config::types::McpServerToolConfig; use codex_config::types::McpServerTransportConfig; +use codex_config::types::SessionPickerViewMode; +use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; #[cfg(unix)] @@ -31,6 +33,34 @@ model_reasoning_effort = "high" assert_eq!(contents, expected); } +#[test] +fn set_service_tier_saves_priority_as_fast() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .set_service_tier(Some(ServiceTier::Fast.request_value().to_string())) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!(contents, "service_tier = \"fast\"\n"); +} + +#[test] +fn set_service_tier_preserves_unknown_service_tier() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .set_service_tier(Some("experimental-tier-id".to_string())) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + assert_eq!(contents, "service_tier = \"experimental-tier-id\"\n"); +} + #[test] fn builder_with_edits_applies_custom_paths() { let tmp = tempdir().expect("tmpdir"); @@ -48,6 +78,41 @@ fn builder_with_edits_applies_custom_paths() { assert_eq!(contents, "enabled = true\n"); } +#[test] +fn session_picker_view_edit_writes_root_tui_setting() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_edits([session_picker_view_edit(SessionPickerViewMode::Dense)]) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[tui] +session_picker_view = "dense" +"#; + assert_eq!(contents, expected); +} + +#[test] +fn session_picker_view_builder_respects_active_profile() { + let tmp = tempdir().expect("tmpdir"); + let codex_home = tmp.path(); + + ConfigEditsBuilder::new(codex_home) + .with_profile(Some("work")) + .set_session_picker_view(SessionPickerViewMode::Dense) + .apply_blocking() + .expect("persist"); + + let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config"); + let expected = r#"[profiles.work.tui] +session_picker_view = "dense" +"#; + assert_eq!(contents, expected); +} + #[test] fn keymap_binding_edit_writes_root_action_binding() { let tmp = tempdir().expect("tmpdir"); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 20b2a923f8..d1145c2e55 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -37,7 +37,6 @@ use codex_config::profile_toml::ConfigProfile; use codex_config::sandbox_mode_requirement_for_permission_profile; use codex_config::types::ApprovalsReviewer; use codex_config::types::AuthCredentialsStoreMode; -use codex_config::types::DEFAULT_OTEL_ENVIRONMENT; use codex_config::types::History; use codex_config::types::McpServerConfig; use codex_config::types::McpServerDisabledReason; @@ -46,14 +45,13 @@ use codex_config::types::MemoriesConfig; use codex_config::types::ModelAvailabilityNuxConfig; use codex_config::types::Notice; use codex_config::types::OAuthCredentialsStoreMode; -use codex_config::types::OtelConfig; -use codex_config::types::OtelConfigToml; -use codex_config::types::OtelExporterKind; +use codex_config::types::SessionPickerViewMode; use codex_config::types::ToolSuggestConfig; use codex_config::types::ToolSuggestDisabledTool; use codex_config::types::ToolSuggestDiscoverable; use codex_config::types::TuiKeymap; use codex_config::types::TuiNotificationSettings; +use codex_config::types::TuiPetAnchor; use codex_config::types::UriBasedFileOpener; use codex_config::types::WindowsSandboxModeToml; use codex_core_plugins::PluginsConfigInput; @@ -67,6 +65,7 @@ use codex_features::FeatureToml; use codex_features::Features; use codex_features::FeaturesToml; use codex_features::MultiAgentV2ConfigToml; +use codex_features::NetworkProxyConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::AuthManagerConfig; use codex_mcp::McpConfig; @@ -101,6 +100,9 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; +use rmcp::model::ElicitationCapability; +use rmcp::model::FormElicitationCapability; +use rmcp::model::UrlElicitationCapability; use serde::Deserialize; use serde::Serialize; use std::collections::BTreeMap; @@ -112,6 +114,7 @@ use std::path::PathBuf; use std::sync::Arc; use crate::config::permissions::BUILT_IN_WORKSPACE_PROFILE; +use crate::config::permissions::apply_network_proxy_feature_config; use crate::config::permissions::builtin_permission_profile; use crate::config::permissions::compile_permission_profile_selection; use crate::config::permissions::default_builtin_permission_profile_name; @@ -129,6 +132,7 @@ pub(crate) mod agent_roles; pub mod edit; mod managed_features; mod network_proxy_spec; +mod otel; mod permissions; #[cfg(test)] mod schema; @@ -380,8 +384,6 @@ pub enum ThreadStoreConfig { /// Persist threads locally using rollout JSONL files and sqlite metadata. #[default] Local, - /// Persist threads through the remote thread-store service. - Remote { endpoint: String }, /// In-memory thread store for test and debug configurations. InMemory { id: String }, } @@ -399,8 +401,8 @@ pub struct Config { /// Optional override of model selection. pub model: Option, - /// Effective service tier preference for new turns (`fast` or `flex`). - pub service_tier: Option, + /// Effective service tier request id preference for new turns. + pub service_tier: Option, /// Model used specifically for review sessions. pub review_model: Option, @@ -463,6 +465,9 @@ pub struct Config { /// Whether to inject the `` developer block. pub include_apps_instructions: bool, + /// Whether to inject the `` developer block. + pub include_collaboration_mode_instructions: bool, + /// Whether to inject the `` developer block. pub include_skill_instructions: bool, @@ -473,13 +478,15 @@ pub struct Config { pub compact_prompt: Option, /// Optional commit attribution text for commit message co-author trailers. + /// This top-level setting only takes effect when `[features].codex_git_commit` + /// is enabled. /// /// - `None`: use default attribution (`Codex `) /// - `Some("")` or whitespace-only: disable commit attribution /// - `Some("...")`: use the provided attribution text verbatim pub commit_attribution: Option, - /// Deprecated optional external notifier command. When set, Codex will spawn this + /// Optional external notifier command. When set, Codex will spawn this /// program after each completed *turn* (i.e. when the agent finishes /// processing a user submission). The value must be the full command /// broken into argv tokens **without** the trailing JSON argument - Codex @@ -498,7 +505,7 @@ pub struct Config { /// notify-send Codex '{"type":"agent-turn-complete","turn-id":"12345"}' /// ``` /// - /// If unset the feature is disabled. Use lifecycle hooks for new automation. + /// If unset the feature is disabled. pub notify: Option>, /// TUI notification settings, including enabled events, delivery method, and focus condition. @@ -516,6 +523,9 @@ pub struct Config { /// Start the composer in Vim mode (`Normal`) by default. pub tui_vim_mode_default: bool, + /// Start the TUI in raw scrollback mode for copy-friendly transcript output. + pub tui_raw_output_mode: bool, + /// Start the TUI in the specified collaboration mode (plan/default). /// Controls whether the TUI uses the terminal's alternate screen buffer. @@ -543,6 +553,15 @@ pub struct Config { /// Syntax highlighting theme override (kebab-case name). pub tui_theme: Option, + /// Pet id preselected by the terminal pet picker. + pub tui_pet: Option, + + /// Vertical anchor used by terminal pet rendering. + pub tui_pet_anchor: TuiPetAnchor, + + /// Preferred layout for resume/fork session picker results. + pub tui_session_picker_view: SessionPickerViewMode, + /// Terminal resize-reflow tuning knobs. pub terminal_resize_reflow: TerminalResizeReflowConfig, @@ -701,7 +720,7 @@ pub struct Config { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: String, - /// Optional path override for the built-in apps MCP server. + /// Optional path override for the host-owned apps MCP server. pub apps_mcp_path_override: Option, /// Machine-local realtime audio device preferences used by realtime voice. @@ -1099,7 +1118,8 @@ impl Config { if let Some(mcp_requirements) = self.config_layer_stack.requirements().mcp_servers.as_ref() && mcp_requirements.value.is_empty() { - // A present empty allowlist bans all MCPs, including plugin MCPs merged above. + // A present empty allowlist bans configurable MCPs, including plugin MCPs merged + // above. filter_mcp_servers_by_requirements(&mut configured_mcp_servers, Some(mcp_requirements)); } @@ -1117,11 +1137,77 @@ impl Config { 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), + client_elicitation_capability: if self.features.enabled(Feature::AuthElicitation) { + ElicitationCapability { + form: Some(FormElicitationCapability::default()), + url: Some(UrlElicitationCapability::default()), + } + } else { + // https://modelcontextprotocol.io/specification/2025-06-18/client/elicitation#capabilities + // indicates this should be an empty object. + ElicitationCapability::default() + }, configured_mcp_servers, plugin_capability_summaries: loaded_plugins.capability_summaries().to_vec(), } } + pub async fn rebuild_preserving_session_layers( + &self, + refreshed_config: &Config, + ) -> std::io::Result { + let mut layers = refreshed_config + .config_layer_stack + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) + .into_iter() + .filter(|layer| !is_session_layer(&layer.name)) + .cloned() + .collect::>(); + layers.extend( + self.config_layer_stack + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ true, + ) + .into_iter() + .filter(|layer| is_session_layer(&layer.name)) + .cloned(), + ); + layers.sort_by_key(|layer| layer.name.precedence()); + + let config_layer_stack = ConfigLayerStack::new( + layers, + refreshed_config.config_layer_stack.requirements().clone(), + refreshed_config + .config_layer_stack + .requirements_toml() + .clone(), + )? + .with_user_and_project_exec_policy_rules_ignored( + refreshed_config + .config_layer_stack + .ignore_user_and_project_exec_policy_rules(), + ); + let cfg: ConfigToml = config_layer_stack + .effective_config() + .try_into() + .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))?; + Self::load_config_with_layer_stack( + LOCAL_FS.as_ref(), + cfg, + ConfigOverrides { + cwd: Some(self.cwd.to_path_buf()), + ..Default::default() + }, + refreshed_config.codex_home.clone(), + config_layer_stack, + ) + .await + } + /// This is the preferred way to create an instance of [Config]. pub async fn load_with_cli_overrides( cli_overrides: Vec<(String, TomlValue)>, @@ -1658,20 +1744,18 @@ fn resolve_tool_suggest_config_from_config( } } -fn thread_store_config( - thread_store: Option, - legacy_remote_endpoint: Option, -) -> ThreadStoreConfig { +fn thread_store_config(thread_store: Option) -> ThreadStoreConfig { match thread_store { Some(ThreadStoreToml::Local {}) => ThreadStoreConfig::Local, - Some(ThreadStoreToml::Remote { endpoint }) => ThreadStoreConfig::Remote { endpoint }, Some(ThreadStoreToml::InMemory { id }) => ThreadStoreConfig::InMemory { id }, - None => legacy_remote_endpoint.map_or(ThreadStoreConfig::Local, |endpoint| { - ThreadStoreConfig::Remote { endpoint } - }), + None => ThreadStoreConfig::Local, } } +fn is_session_layer(source: &ConfigLayerSource) -> bool { + matches!(source, ConfigLayerSource::SessionFlags) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PermissionConfigSyntax { Legacy, @@ -1788,7 +1872,7 @@ pub struct ConfigOverrides { pub permission_profile: Option, pub default_permissions: Option, pub model_provider: Option, - pub service_tier: Option>, + pub service_tier: Option>, pub config_profile: Option, pub codex_self_exe: Option, pub codex_linux_sandbox_exe: Option, @@ -1954,6 +2038,13 @@ fn apps_mcp_path_override_toml_config( } } +fn network_proxy_toml_config(features: Option<&FeaturesToml>) -> Option<&NetworkProxyConfigToml> { + match features?.network_proxy.as_ref()? { + FeatureToml::Enabled(_) => None, + FeatureToml::Config(config) => Some(config), + } +} + pub(crate) fn resolve_web_search_mode_for_turn( web_search_mode: &Constrained, permission_profile: &PermissionProfile, @@ -2018,6 +2109,13 @@ impl Config { ) -> std::io::Result { // Keep the large config-construction future off small test thread stacks. Box::pin(async move { + if cfg.experimental_thread_store_endpoint.is_some() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "`experimental_thread_store_endpoint` is no longer supported; remove it from config.toml", + )); + } + 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 @@ -2027,6 +2125,7 @@ impl Config { approvals_reviewer: mut constrained_approvals_reviewer, permission_profile: mut constrained_permission_profile, web_search_mode: mut constrained_web_search_mode, + allow_managed_hooks_only: _, feature_requirements, managed_hooks: _, mcp_servers, @@ -2137,6 +2236,7 @@ impl Config { feature_requirements, &mut startup_warnings, )?; + let enable_network_proxy = features.enabled(Feature::NetworkProxy); let windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg, &config_profile); let windows_sandbox_private_desktop = resolve_windows_sandbox_private_desktop(&cfg, &config_profile); @@ -2220,7 +2320,7 @@ impl Config { let using_implicit_builtin_profile = permission_config_syntax.is_none() && default_permissions.is_none(); let ( - configured_network_proxy_config, + mut configured_network_proxy_config, permission_profile, file_system_sandbox_policy, mut active_permission_profile, @@ -2433,6 +2533,22 @@ impl Config { None, ) }; + if enable_network_proxy && permission_profile.network_sandbox_policy().is_enabled() { + if let Some(network_proxy) = network_proxy_toml_config(cfg.features.as_ref()) { + apply_network_proxy_feature_config( + &mut configured_network_proxy_config, + network_proxy, + ); + } + if let Some(network_proxy) = network_proxy_toml_config(config_profile.features.as_ref()) + { + apply_network_proxy_feature_config( + &mut configured_network_proxy_config, + network_proxy, + ); + } + configured_network_proxy_config.network.enabled = true; + } let approval_policy_was_explicit = approval_policy_override.is_some() || config_profile.approval_policy.is_some() || cfg.approval_policy.is_some(); @@ -2658,14 +2774,15 @@ impl Config { } None => config_profile.service_tier.or(cfg.service_tier), }; - let service_tier = match service_tier { - Some(ServiceTier::Fast) if features.enabled(Feature::FastMode) => { - Some(ServiceTier::Fast) + let service_tier = service_tier.and_then(|service_tier| { + match ServiceTier::from_request_value(&service_tier) { + Some(ServiceTier::Fast) => features + .enabled(Feature::FastMode) + .then(|| ServiceTier::Fast.request_value().to_string()), + Some(ServiceTier::Flex) => Some(ServiceTier::Flex.request_value().to_string()), + None => Some(service_tier), } - Some(ServiceTier::Fast) => None, - Some(ServiceTier::Flex) => Some(ServiceTier::Flex), - None => None, - }; + }); let compact_prompt = compact_prompt.or(cfg.compact_prompt).and_then(|value| { let trimmed = value.trim(); @@ -2703,6 +2820,10 @@ impl Config { .include_apps_instructions .or(cfg.include_apps_instructions) .unwrap_or(true); + let include_collaboration_mode_instructions = config_profile + .include_collaboration_mode_instructions + .or(cfg.include_collaboration_mode_instructions) + .unwrap_or(true); let include_skill_instructions = cfg .skills .as_ref() @@ -2799,12 +2920,6 @@ impl Config { } }) .map_err(std::io::Error::from)?; - - if cfg!(target_os = "windows") { - startup_warnings.push(format!( - "managed filesystem deny_read from {filesystem_requirements_source} is only enforced for direct file tools on Windows; shell subprocess reads are not sandboxed" - )); - } } apply_requirement_constrained_value( "approvals_reviewer", @@ -2892,6 +3007,7 @@ impl Config { .value .set(effective_permission_profile) .map_err(std::io::Error::from)?; + let otel = otel::resolve_config(cfg.otel.unwrap_or_default(), &mut startup_warnings); let config = Self { model, service_tier, @@ -2923,6 +3039,7 @@ impl Config { commit_attribution, include_permissions_instructions, include_apps_instructions, + include_collaboration_mode_instructions, include_skill_instructions, include_environment_context, // The config.toml omits "_mode" because it's a config file. However, "_mode" @@ -3038,10 +3155,7 @@ impl Config { experimental_realtime_ws_startup_context: cfg.experimental_realtime_ws_startup_context, experimental_realtime_start_instructions: cfg.experimental_realtime_start_instructions, experimental_thread_config_endpoint: cfg.experimental_thread_config_endpoint, - experimental_thread_store: thread_store_config( - cfg.experimental_thread_store, - cfg.experimental_thread_store_endpoint, - ), + experimental_thread_store: thread_store_config(cfg.experimental_thread_store), forced_chatgpt_workspace_id, forced_login_method, include_apply_patch_tool: include_apply_patch_tool_flag, @@ -3089,6 +3203,11 @@ impl Config { .as_ref() .map(|t| t.vim_mode_default) .unwrap_or(false), + tui_raw_output_mode: cfg + .tui + .as_ref() + .map(|t| t.raw_output_mode) + .unwrap_or(false), tui_alternate_screen: cfg .tui .as_ref() @@ -3102,29 +3221,25 @@ impl Config { .unwrap_or(true), tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()), tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()), + tui_pet: cfg.tui.as_ref().and_then(|t| t.pet.clone()), + tui_pet_anchor: cfg + .tui + .as_ref() + .map(|t| t.pet_anchor) + .unwrap_or_default(), + tui_session_picker_view: config_profile + .tui + .as_ref() + .and_then(|t| t.session_picker_view) + .or_else(|| cfg.tui.as_ref().and_then(|t| t.session_picker_view)) + .unwrap_or_default(), terminal_resize_reflow, tui_keymap: cfg .tui .as_ref() .map(|t| t.keymap.clone()) .unwrap_or_default(), - otel: { - let t: OtelConfigToml = cfg.otel.unwrap_or_default(); - let log_user_prompt = t.log_user_prompt.unwrap_or(false); - let environment = t - .environment - .unwrap_or(DEFAULT_OTEL_ENVIRONMENT.to_string()); - let exporter = t.exporter.unwrap_or(OtelExporterKind::None); - let trace_exporter = t.trace_exporter.unwrap_or_else(|| exporter.clone()); - let metrics_exporter = t.metrics_exporter.unwrap_or(OtelExporterKind::Statsig); - OtelConfig { - log_user_prompt, - environment, - exporter, - trace_exporter, - metrics_exporter, - } - }, + otel, }; Ok(config) }) diff --git a/codex-rs/core/src/config/otel.rs b/codex-rs/core/src/config/otel.rs new file mode 100644 index 0000000000..cb65d304d1 --- /dev/null +++ b/codex-rs/core/src/config/otel.rs @@ -0,0 +1,117 @@ +use std::collections::BTreeMap; +use std::fmt::Display; + +use codex_config::types::DEFAULT_OTEL_ENVIRONMENT; +use codex_config::types::OtelConfig; +use codex_config::types::OtelConfigToml; +use codex_config::types::OtelExporterKind; + +pub(crate) fn resolve_config( + config: OtelConfigToml, + startup_warnings: &mut Vec, +) -> OtelConfig { + let log_user_prompt = config.log_user_prompt.unwrap_or(false); + let environment = config + .environment + .unwrap_or_else(|| DEFAULT_OTEL_ENVIRONMENT.to_string()); + let exporter = config.exporter.unwrap_or(OtelExporterKind::None); + // OTLP HTTP endpoints are signal-specific in our config, so enabling log + // export must not implicitly send spans to a /v1/logs endpoint. + let trace_exporter = config.trace_exporter.unwrap_or(OtelExporterKind::None); + let metrics_exporter = config.metrics_exporter.unwrap_or(OtelExporterKind::Statsig); + // Provider initialization installs process-global OTEL state. Sanitize + // user-editable trace metadata here so malformed config is reported as a + // startup warning instead of making startup fail. + let span_attributes = resolve_span_attributes(config.span_attributes, startup_warnings); + let tracestate = resolve_tracestate(config.tracestate, startup_warnings); + + OtelConfig { + log_user_prompt, + environment, + exporter, + trace_exporter, + metrics_exporter, + span_attributes, + tracestate, + } +} + +fn resolve_span_attributes( + span_attributes: Option>, + startup_warnings: &mut Vec, +) -> BTreeMap { + let Some(span_attributes) = span_attributes else { + return BTreeMap::new(); + }; + + let mut valid_attributes = BTreeMap::new(); + for (key, value) in span_attributes { + let attribute = BTreeMap::from([(key.clone(), value.clone())]); + if let Err(err) = codex_otel::validate_span_attributes(&attribute) { + push_invalid_config_warning("otel.span_attributes", err, startup_warnings); + continue; + } + valid_attributes.insert(key, value); + } + + valid_attributes +} + +fn resolve_tracestate( + tracestate: Option>>, + startup_warnings: &mut Vec, +) -> BTreeMap> { + let Some(tracestate) = tracestate else { + return BTreeMap::new(); + }; + + let mut valid_entries = BTreeMap::new(); + for (member_key, fields) in tracestate { + let fields = resolve_tracestate_member_fields(&member_key, fields, startup_warnings); + if fields.is_empty() { + continue; + } + if let Err(err) = codex_otel::validate_tracestate_member(&member_key, &fields) { + push_invalid_config_warning("otel.tracestate", err, startup_warnings); + continue; + } + valid_entries.insert(member_key, fields); + } + + // Tracestate members can be valid individually while the combined W3C + // tracestate header is not, so validate the filtered set before handing it + // to provider initialization. + if let Err(err) = codex_otel::validate_tracestate_entries(&valid_entries) { + push_invalid_config_warning("otel.tracestate", err, startup_warnings); + return BTreeMap::new(); + } + + valid_entries +} + +fn resolve_tracestate_member_fields( + member_key: &str, + fields: BTreeMap, + startup_warnings: &mut Vec, +) -> BTreeMap { + let mut valid_fields = BTreeMap::new(); + for (field_key, value) in fields { + let field = BTreeMap::from([(field_key.clone(), value.clone())]); + if let Err(err) = codex_otel::validate_tracestate_member(member_key, &field) { + push_invalid_config_warning("otel.tracestate", err, startup_warnings); + continue; + } + valid_fields.insert(field_key, value); + } + valid_fields +} + +fn push_invalid_config_warning( + config_key: &str, + err: impl Display, + startup_warnings: &mut Vec, +) { + let message = format!("Ignoring invalid `{config_key}` config: {err}"); + tracing::warn!("{message}"); + startup_warnings.push(message); +} diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index b51a8973a3..6b6021ad34 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -6,10 +6,19 @@ use std::path::PathBuf; use codex_config::permissions_toml::FilesystemPermissionToml; use codex_config::permissions_toml::FilesystemPermissionsToml; +use codex_config::permissions_toml::NetworkDomainPermissionToml; +use codex_config::permissions_toml::NetworkDomainPermissionsToml; use codex_config::permissions_toml::NetworkToml; +use codex_config::permissions_toml::NetworkUnixSocketPermissionToml; +use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml; use codex_config::permissions_toml::PermissionProfileToml; use codex_config::permissions_toml::PermissionsToml; use codex_config::types::SandboxWorkspaceWrite; +use codex_features::NetworkProxyConfigToml; +use codex_features::NetworkProxyDomainPermissionToml; +use codex_features::NetworkProxyModeToml; +use codex_features::NetworkProxyUnixSocketPermissionToml; +use codex_network_proxy::NetworkMode; use codex_network_proxy::NetworkProxyConfig; #[cfg(test)] use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission; @@ -108,35 +117,70 @@ pub(crate) fn network_proxy_config_from_profile_network( NetworkProxyConfig::default, NetworkToml::to_network_proxy_config, ); - // Profile `network.enabled` controls sandbox network access. Do not start a - // managed proxy for that bit alone, but keep the proxy enabled when the - // profile also supplied policy that only the proxy can enforce. - config.network.enabled = network.is_some_and(profile_network_requires_proxy); + // Profile `network.enabled` controls sandbox network access. Profiles may + // provide proxy settings for the feature gate to consume when that network + // access is enabled, but they do not start the managed proxy on their own. + config.network.enabled = false; config } -fn profile_network_requires_proxy(network: &NetworkToml) -> bool { - if network.enabled != Some(true) { - return false; - } - - network.proxy_url.is_some() - || network.enable_socks5 == Some(true) - || network.socks_url.is_some() - || network.enable_socks5_udp == Some(true) - || network.allow_upstream_proxy == Some(true) - || network.dangerously_allow_non_loopback_proxy == Some(true) - || network.dangerously_allow_all_unix_sockets == Some(true) - || network.mode.is_some() - || network +pub(crate) fn apply_network_proxy_feature_config( + config: &mut NetworkProxyConfig, + feature_config: &NetworkProxyConfigToml, +) { + NetworkToml { + enabled: feature_config.enabled, + proxy_url: feature_config.proxy_url.clone(), + enable_socks5: feature_config.enable_socks5, + socks_url: feature_config.socks_url.clone(), + enable_socks5_udp: feature_config.enable_socks5_udp, + allow_upstream_proxy: feature_config.allow_upstream_proxy, + dangerously_allow_non_loopback_proxy: feature_config.dangerously_allow_non_loopback_proxy, + dangerously_allow_all_unix_sockets: feature_config.dangerously_allow_all_unix_sockets, + mode: feature_config.mode.map(|mode| match mode { + NetworkProxyModeToml::Limited => NetworkMode::Limited, + NetworkProxyModeToml::Full => NetworkMode::Full, + }), + domains: feature_config .domains .as_ref() - .is_some_and(|domains| !domains.is_empty()) - || network - .unix_sockets - .as_ref() - .is_some_and(|unix_sockets| !unix_sockets.is_empty()) - || network.allow_local_binding == Some(true) + .map(|domains| NetworkDomainPermissionsToml { + entries: domains + .iter() + .map(|(pattern, permission)| { + let permission = match permission { + NetworkProxyDomainPermissionToml::Allow => { + NetworkDomainPermissionToml::Allow + } + NetworkProxyDomainPermissionToml::Deny => { + NetworkDomainPermissionToml::Deny + } + }; + (pattern.clone(), permission) + }) + .collect(), + }), + unix_sockets: feature_config.unix_sockets.as_ref().map(|unix_sockets| { + NetworkUnixSocketPermissionsToml { + entries: unix_sockets + .iter() + .map(|(path, permission)| { + let permission = match permission { + NetworkProxyUnixSocketPermissionToml::Allow => { + NetworkUnixSocketPermissionToml::Allow + } + NetworkProxyUnixSocketPermissionToml::None => { + NetworkUnixSocketPermissionToml::None + } + }; + (path.clone(), permission) + }) + .collect(), + } + }), + allow_local_binding: feature_config.allow_local_binding, + } + .apply_to_network_proxy_config(config); } pub(crate) fn resolve_permission_profile<'a>( diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index 7dd2bcdefe..6ae9307f02 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -247,7 +247,7 @@ fn profile_network_proxy_config_keeps_proxy_disabled_for_bare_network_access() { } #[test] -fn profile_network_proxy_config_enables_proxy_for_proxy_policy() { +fn profile_network_proxy_config_keeps_proxy_disabled_for_proxy_policy() { let config = network_proxy_config_from_profile_network(Some(&NetworkToml { enabled: Some(true), proxy_url: Some("http://127.0.0.1:43128".to_string()), @@ -261,7 +261,7 @@ fn profile_network_proxy_config_enables_proxy_for_proxy_policy() { ..Default::default() })); - assert!(config.network.enabled); + assert!(!config.network.enabled); assert_eq!(config.network.proxy_url, "http://127.0.0.1:43128"); assert!(!config.network.enable_socks5); assert_eq!( diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index b83be7dc8a..f4ee42c821 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -6,22 +6,18 @@ use std::sync::Mutex as StdMutex; use std::time::Duration; use std::time::Instant; -use anyhow::Context; use async_channel::unbounded; -use codex_api::SharedAuthProvider; pub use codex_app_server_protocol::AppBranding; pub use codex_app_server_protocol::AppInfo; pub use codex_app_server_protocol::AppMetadata; -use codex_connectors::AllConnectorsCacheKey; -use codex_connectors::DirectoryListResponse; +use codex_connectors::ConnectorDirectoryCacheContext; +use codex_connectors::ConnectorDirectoryCacheKey; use codex_exec_server::EnvironmentManager; -use codex_exec_server::EnvironmentManagerArgs; use codex_exec_server::ExecServerRuntimePaths; use codex_protocol::models::PermissionProfile; use codex_tools::DiscoverableTool; use rmcp::model::ToolAnnotations; use serde::Deserialize; -use serde::de::DeserializeOwned; use tracing::warn; use crate::config::Config; @@ -36,7 +32,6 @@ use codex_core_plugins::PluginsManager; use codex_features::Feature; use codex_login::AuthManager; use codex_login::CodexAuth; -use codex_login::default_client::create_client; use codex_login::default_client::originator; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::McpConnectionManager; @@ -45,10 +40,10 @@ use codex_mcp::ToolInfo; use codex_mcp::ToolPluginProvenance; use codex_mcp::codex_apps_tools_cache_key; use codex_mcp::compute_auth_statuses; +use codex_mcp::host_owned_codex_apps_enabled; use codex_mcp::with_codex_apps_mcp; const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30); -const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct AppToolPolicy { @@ -119,9 +114,11 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( auth: Option<&CodexAuth>, accessible_connectors: &[AppInfo], ) -> anyhow::Result> { - let directory_connectors = - list_directory_connectors_for_tool_suggest_with_auth(config, auth).await?; let connector_ids = tool_suggest_connector_ids(config).await; + let directory_connectors = codex_connectors::merge::merge_plugin_connectors( + cached_directory_connectors_for_tool_suggest_with_auth(config, auth).await, + connector_ids.iter().cloned(), + ); let discoverable_connectors = codex_connectors::filter::filter_tool_suggest_discoverable_connectors( directory_connectors, @@ -164,7 +161,7 @@ pub async fn list_cached_accessible_connectors_from_mcp_tools( pub(crate) fn refresh_accessible_connectors_cache_from_mcp_tools( config: &Config, auth: Option<&CodexAuth>, - mcp_tools: &HashMap, + mcp_tools: &[ToolInfo], ) { if !config.features.enabled(Feature::Apps) { return; @@ -201,7 +198,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( config.codex_linux_sandbox_exe.clone(), )?; let environment_manager = - EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await; + EnvironmentManager::from_codex_home(config.codex_home.clone(), local_runtime_paths).await?; list_accessible_connectors_from_mcp_tools_with_environment_manager( config, force_refetch, @@ -246,6 +243,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( let mcp_config = config.to_mcp_config(plugins_manager.as_ref()).await; let mcp_servers = with_codex_apps_mcp(HashMap::new(), auth.as_ref(), &mcp_config); + let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(&mcp_config, auth.as_ref()); if mcp_servers.is_empty() { return Ok(AccessibleConnectorsStatus { connectors: Vec::new(), @@ -278,8 +276,11 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf()), config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth.as_ref()), + host_owned_codex_apps_enabled, + mcp_config.client_elicitation_capability, ToolPluginProvenance::default(), auth.as_ref(), + /*elicitation_reviewer*/ None, ) .await; @@ -317,7 +318,8 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( true } else if tools.is_empty() { let timeout = cfg - .startup_timeout_sec + .configured_config() + .and_then(|config| config.startup_timeout_sec) .unwrap_or(CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS); let ready = mcp_connection_manager .wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, timeout) @@ -431,12 +433,12 @@ async fn tool_suggest_connector_ids(config: &Config) -> HashSet { connector_ids } -async fn list_directory_connectors_for_tool_suggest_with_auth( +async fn cached_directory_connectors_for_tool_suggest_with_auth( config: &Config, auth: Option<&CodexAuth>, -) -> anyhow::Result> { +) -> Vec { if !config.features.enabled(Feature::Apps) { - return Ok(Vec::new()); + return Vec::new(); } let loaded_auth; @@ -449,75 +451,31 @@ async fn list_directory_connectors_for_tool_suggest_with_auth( loaded_auth.as_ref() }; let Some(auth) = auth.filter(|auth| auth.uses_codex_backend()) else { - return Ok(Vec::new()); + return Vec::new(); }; let account_id = match auth.get_account_id() { Some(account_id) if !account_id.is_empty() => account_id, - _ => return Ok(Vec::new()), + _ => return Vec::new(), }; - let auth_provider = codex_model_provider::auth_provider_from_auth(auth); let is_workspace_account = auth.is_workspace_account(); - let cache_key = AllConnectorsCacheKey::new( - config.chatgpt_base_url.clone(), - Some(account_id.clone()), - auth.get_chatgpt_user_id(), - is_workspace_account, + let cache_context = ConnectorDirectoryCacheContext::new( + config.codex_home.to_path_buf(), + ConnectorDirectoryCacheKey::new( + config.chatgpt_base_url.clone(), + Some(account_id), + auth.get_chatgpt_user_id(), + is_workspace_account, + ), ); - codex_connectors::list_all_connectors_with_options( - cache_key, - is_workspace_account, - /*force_refetch*/ false, - |path| { - let auth_provider = auth_provider.clone(); - async move { - chatgpt_get_request_with_auth_provider::( - config, - path, - auth_provider, - ) - .await - } - }, - ) - .await + codex_connectors::cached_directory_connectors(&cache_context).unwrap_or_default() } -async fn chatgpt_get_request_with_auth_provider( - config: &Config, - path: String, - auth_provider: SharedAuthProvider, -) -> anyhow::Result { - let client = create_client(); - let url = format!("{}{}", config.chatgpt_base_url, path); - let response = client - .get(&url) - .headers(auth_provider.to_auth_headers()) - .header("Content-Type", "application/json") - .timeout(DIRECTORY_CONNECTORS_TIMEOUT) - .send() - .await - .context("failed to send request")?; - - if response.status().is_success() { - response - .json() - .await - .context("failed to parse JSON response") - } else { - let status = response.status(); - let body = response.text().await.unwrap_or_default(); - anyhow::bail!("request failed with status {status}: {body}"); - } -} - -pub(crate) fn accessible_connectors_from_mcp_tools( - mcp_tools: &HashMap, -) -> Vec { +pub(crate) fn accessible_connectors_from_mcp_tools(mcp_tools: &[ToolInfo]) -> Vec { // ToolInfo already carries plugin provenance, so app-level plugin sources // can be derived here instead of requiring a separate enrichment pass. - let tools = mcp_tools.values().filter_map(|tool| { + let tools = mcp_tools.iter().filter_map(|tool| { if tool.server_name != CODEX_APPS_MCP_SERVER_NAME { return None; } @@ -525,7 +483,7 @@ pub(crate) fn accessible_connectors_from_mcp_tools( Some(codex_connectors::accessible::AccessibleConnectorTool { connector_id: connector_id.to_string(), connector_name: tool.connector_name.clone(), - connector_description: tool.connector_description.clone(), + connector_description: tool.namespace_description.clone(), plugin_display_names: tool.plugin_display_names.clone(), }) }); @@ -578,12 +536,18 @@ pub(crate) fn app_tool_policy( annotations: Option<&ToolAnnotations>, ) -> AppToolPolicy { let apps_config = read_apps_config(config); + let managed_approval = managed_app_tool_approval( + config.config_layer_stack.requirements_toml().apps.as_ref(), + connector_id, + tool_name, + ); app_tool_policy_from_apps_config( apps_config.as_ref(), connector_id, tool_name, tool_title, annotations, + managed_approval, ) } @@ -636,14 +600,29 @@ fn apply_requirements_apps_constraints( }; for (app_id, requirement) in &requirements_apps_config.apps { - if requirement.enabled != Some(false) { - continue; + if requirement.enabled == Some(false) { + let app = apps_config.apps.entry(app_id.clone()).or_default(); + app.enabled = false; } - let app = apps_config.apps.entry(app_id.clone()).or_default(); - app.enabled = false; } } +fn managed_app_tool_approval( + requirements_apps_config: Option<&AppsRequirementsToml>, + connector_id: Option<&str>, + tool_name: &str, +) -> Option { + let connector_id = connector_id?; + requirements_apps_config? + .apps + .get(connector_id)? + .tools + .as_ref()? + .tools + .get(tool_name)? + .approval_mode +} + fn app_is_enabled(apps_config: &AppsConfigToml, connector_id: Option<&str>) -> bool { let default_enabled = apps_config .default @@ -663,9 +642,13 @@ fn app_tool_policy_from_apps_config( tool_name: &str, tool_title: Option<&str>, annotations: Option<&ToolAnnotations>, + managed_approval: Option, ) -> AppToolPolicy { let Some(apps_config) = apps_config else { - return AppToolPolicy::default(); + return AppToolPolicy { + approval: managed_approval.unwrap_or(AppToolApproval::Auto), + ..Default::default() + }; }; let app = connector_id.and_then(|connector_id| apps_config.apps.get(connector_id)); @@ -676,8 +659,8 @@ fn app_tool_policy_from_apps_config( .get(tool_name) .or_else(|| tool_title.and_then(|title| tools.tools.get(title))) }); - let approval = tool_config - .and_then(|tool| tool.approval_mode) + let approval = managed_approval + .or_else(|| tool_config.and_then(|tool| tool.approval_mode)) .or_else(|| app.and_then(|app| app.default_tools_approval_mode)) .unwrap_or(AppToolApproval::Auto); diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index b3538d1ff0..c5798d7c14 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -2,6 +2,8 @@ use super::*; use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use codex_config::AppRequirementToml; +use codex_config::AppToolRequirementToml; +use codex_config::AppToolsRequirementsToml; use codex_config::AppsRequirementsToml; use codex_config::CloudRequirementsLoader; use codex_config::ConfigLayerStack; @@ -19,6 +21,7 @@ use codex_connectors::metadata::connector_install_url; use codex_connectors::metadata::connector_mention_slug; use codex_connectors::metadata::sanitize_name; use codex_features::Feature; +use codex_login::CodexAuth; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::ToolInfo; use codex_utils_absolute_path::AbsolutePathBuf; @@ -117,13 +120,14 @@ fn codex_app_tool( ToolInfo { server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + supports_parallel_tool_calls: false, + server_origin: None, callable_name: tool_name.to_string(), callable_namespace: tool_namespace, - server_instructions: None, + namespace_description: None, tool: test_tool_definition(tool_name), connector_id: Some(connector_id.to_string()), connector_name: connector_name.map(ToOwned::to_owned), - connector_description: None, plugin_display_names: plugin_names(plugin_display_names), } } @@ -173,40 +177,32 @@ fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() { #[test] fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() { - let tools = HashMap::from([ - ( - "mcp__codex_apps__calendar_list_events".to_string(), - codex_app_tool( - "calendar_list_events", - "calendar", - /*connector_name*/ None, - &["sample", "sample"], - ), + let tools = vec![ + codex_app_tool( + "calendar_list_events", + "calendar", + /*connector_name*/ None, + &["sample", "sample"], ), - ( - "mcp__codex_apps__calendar_create_event".to_string(), - codex_app_tool( - "calendar_create_event", - "calendar", - Some("Google Calendar"), - &["beta", "sample"], - ), + codex_app_tool( + "calendar_create_event", + "calendar", + Some("Google Calendar"), + &["beta", "sample"], ), - ( - "mcp__sample__echo".to_string(), - ToolInfo { - server_name: "sample".to_string(), - callable_name: "echo".to_string(), - callable_namespace: "sample".to_string(), - server_instructions: None, - tool: test_tool_definition("echo"), - connector_id: None, - connector_name: None, - connector_description: None, - plugin_display_names: plugin_names(&["ignored"]), - }, - ), - ]); + ToolInfo { + server_name: "sample".to_string(), + supports_parallel_tool_calls: false, + server_origin: None, + callable_name: "echo".to_string(), + callable_namespace: "sample".to_string(), + namespace_description: None, + tool: test_tool_definition("echo"), + connector_id: None, + connector_name: None, + plugin_display_names: plugin_names(&["ignored"]), + }, + ]; let connectors = accessible_connectors_from_mcp_tools(&tools); @@ -240,26 +236,20 @@ async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_instal .expect("config should load"); let _ = config.features.set_enabled(Feature::Apps, /*enabled*/ true); let cache_key = accessible_connectors_cache_key(&config, /*auth*/ None); - let tools = HashMap::from([ - ( - "mcp__codex_apps__calendar_list_events".to_string(), - codex_app_tool( - "calendar_list_events", - "calendar", - Some("Google Calendar"), - &["calendar-plugin"], - ), + let tools = vec![ + codex_app_tool( + "calendar_list_events", + "calendar", + Some("Google Calendar"), + &["calendar-plugin"], ), - ( - "mcp__codex_apps__openai_hidden".to_string(), - codex_app_tool( - "openai_hidden", - "connector_openai_hidden", - Some("Hidden"), - &[], - ), + codex_app_tool( + "openai_hidden", + "connector_openai_hidden", + Some("Hidden"), + &[], ), - ]); + ]; let cached = with_accessible_connectors_cache_cleared(|| { refresh_accessible_connectors_cache_from_mcp_tools(&config, /*auth*/ None, &tools); @@ -317,30 +307,28 @@ fn merge_connectors_unions_and_dedupes_plugin_display_names() { #[test] fn accessible_connectors_from_mcp_tools_preserves_description() { - let mcp_tools = HashMap::from([( - "mcp__codex_apps__calendar_create_event".to_string(), - ToolInfo { - server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), - callable_name: "calendar_create_event".to_string(), - callable_namespace: "mcp__codex_apps__calendar".to_string(), - server_instructions: None, - tool: Tool { - name: "calendar_create_event".to_string().into(), - title: None, - description: Some("Create a calendar event".into()), - input_schema: Arc::new(JsonObject::default()), - output_schema: None, - annotations: None, - execution: None, - icons: None, - meta: None, - }, - connector_id: Some("calendar".to_string()), - connector_name: Some("Calendar".to_string()), - connector_description: Some("Plan events".to_string()), - plugin_display_names: Vec::new(), + let mcp_tools = vec![ToolInfo { + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + supports_parallel_tool_calls: false, + server_origin: None, + callable_name: "calendar_create_event".to_string(), + callable_namespace: "mcp__codex_apps__calendar".to_string(), + namespace_description: Some("Plan events".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::default()), + 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(), + }]; assert_eq!( accessible_connectors_from_mcp_tools(&mcp_tools), @@ -379,6 +367,7 @@ fn app_tool_policy_uses_global_defaults_for_destructive_hints() { "events/create", /*tool_title*/ None, Some(&annotations(Some(true), /*open_world_hint*/ None)), + /*managed_approval*/ None, ); assert_eq!( @@ -407,6 +396,7 @@ fn app_tool_policy_defaults_missing_destructive_hint_to_true() { "events/create", /*tool_title*/ None, Some(&annotations(/*destructive_hint*/ None, Some(false))), + /*managed_approval*/ None, ); assert_eq!( @@ -435,6 +425,7 @@ fn app_tool_policy_defaults_missing_open_world_hint_to_true() { "events/create", /*tool_title*/ None, Some(&annotations(Some(false), /*open_world_hint*/ None)), + /*managed_approval*/ None, ); assert_eq!( @@ -503,6 +494,7 @@ fn requirements_disabled_connector_overrides_enabled_connector() { "connector_123123".to_string(), AppRequirementToml { enabled: Some(false), + tools: None, }, )]), }; @@ -535,6 +527,7 @@ fn requirements_enabled_does_not_override_disabled_connector() { "connector_123123".to_string(), AppRequirementToml { enabled: Some(true), + tools: None, }, )]), }; @@ -568,6 +561,7 @@ enabled = true "connector_123123".to_string(), AppRequirementToml { enabled: Some(false), + tools: None, }, )]), }), @@ -611,6 +605,7 @@ async fn cloud_requirements_disable_connector_applies_without_user_apps_table() "connector_123123".to_string(), AppRequirementToml { enabled: Some(false), + tools: None, }, )]), }), @@ -661,6 +656,7 @@ async fn local_requirements_disable_connector_overrides_user_apps_config() { "connector_123123".to_string(), AppRequirementToml { enabled: Some(false), + tools: None, }, )]), }), @@ -712,6 +708,7 @@ async fn local_requirements_disable_connector_applies_without_user_apps_table() "connector_123123".to_string(), AppRequirementToml { enabled: Some(false), + tools: None, }, )]), }), @@ -753,6 +750,7 @@ async fn with_app_enabled_state_preserves_unrelated_disabled_connector() { "connector_drive".to_string(), AppRequirementToml { enabled: Some(false), + tools: None, }, )]), }), @@ -793,6 +791,7 @@ fn app_tool_policy_honors_default_app_enabled_false() { Some(&annotations( /*destructive_hint*/ None, /*open_world_hint*/ None, )), + /*managed_approval*/ None, ); assert_eq!( @@ -804,6 +803,210 @@ fn app_tool_policy_honors_default_app_enabled_false() { ); } +#[test] +fn app_tool_policy_uses_managed_approval_without_apps_config() { + let policy = app_tool_policy_from_apps_config( + /*apps_config*/ None, + Some("calendar"), + "events/list", + /*tool_title*/ None, + /*annotations*/ None, + Some(AppToolApproval::Approve), + ); + + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Approve, + } + ); +} + +fn app_tool_requirements( + app_id: &str, + tool_name: &str, + approval_mode: AppToolApproval, +) -> AppsRequirementsToml { + AppsRequirementsToml { + apps: BTreeMap::from([( + app_id.to_string(), + AppRequirementToml { + enabled: None, + tools: Some(AppToolsRequirementsToml { + tools: BTreeMap::from([( + tool_name.to_string(), + AppToolRequirementToml { + approval_mode: Some(approval_mode), + }, + )]), + }), + }, + )]), + } +} + +#[test] +fn managed_app_tool_approval_uses_raw_tool_name() { + let requirements_apps = app_tool_requirements( + "connector_123123", + "calendar/list_events", + AppToolApproval::Approve, + ); + + assert_eq!( + managed_app_tool_approval( + Some(&requirements_apps), + Some("connector_123123"), + "calendar/list_events", + ), + Some(AppToolApproval::Approve) + ); + assert_eq!( + managed_app_tool_approval( + Some(&requirements_apps), + Some("connector_123123"), + "calendar/create_event", + ), + None + ); +} + +#[tokio::test] +async fn cloud_requirements_tool_approval_overrides_user_apps_config() { + let codex_home = tempdir().expect("tempdir should succeed"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +[apps.connector_123123.tools."calendar/list_events"] +approval_mode = "prompt" +"#, + ) + .expect("write config"); + + let requirements = ConfigRequirementsToml { + apps: Some(app_tool_requirements( + "connector_123123", + "calendar/list_events", + AppToolApproval::Approve, + )), + ..Default::default() + }; + + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_requirements(CloudRequirementsLoader::new(async move { + Ok(Some(requirements)) + })) + .build() + .await + .expect("config should build"); + + let policy = app_tool_policy( + &config, + Some("connector_123123"), + "calendar/list_events", + /*tool_title*/ None, + /*annotations*/ None, + ); + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Approve, + } + ); +} + +#[tokio::test] +async fn local_requirements_tool_approval_overrides_user_apps_config() { + let codex_home = tempdir().expect("tempdir should succeed"); + let config_toml_path = + AbsolutePathBuf::try_from(codex_home.path().join(CONFIG_TOML_FILE)).expect("abs path"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await + .expect("config should build"); + + let requirements = ConfigRequirementsToml { + apps: Some(app_tool_requirements( + "connector_123123", + "calendar/list_events", + AppToolApproval::Approve, + )), + ..Default::default() + }; + config.config_layer_stack = + ConfigLayerStack::new(Vec::new(), ConfigRequirements::default(), requirements) + .expect("requirements stack") + .with_user_config( + &config_toml_path, + toml::from_str::( + r#" +[apps.connector_123123.tools."calendar/list_events"] +approval_mode = "prompt" +"#, + ) + .expect("apps config"), + ); + + let policy = app_tool_policy( + &config, + Some("connector_123123"), + "calendar/list_events", + /*tool_title*/ None, + /*annotations*/ None, + ); + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Approve, + } + ); +} + +#[tokio::test] +async fn local_requirements_tool_approval_does_not_match_tool_title() { + let codex_home = tempdir().expect("tempdir should succeed"); + let mut config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await + .expect("config should build"); + + let requirements = ConfigRequirementsToml { + apps: Some(app_tool_requirements( + "connector_123123", + "calendar/list_events", + AppToolApproval::Approve, + )), + ..Default::default() + }; + config.config_layer_stack = + ConfigLayerStack::new(Vec::new(), ConfigRequirements::default(), requirements) + .expect("requirements stack"); + + let policy = app_tool_policy( + &config, + Some("connector_123123"), + "calendar/create_event", + Some("calendar/list_events"), + /*annotations*/ None, + ); + assert_eq!( + policy, + AppToolPolicy { + enabled: true, + approval: AppToolApproval::Auto, + } + ); +} + #[test] fn app_tool_policy_allows_per_app_enable_when_default_is_disabled() { let apps_config = AppsConfigToml { @@ -833,6 +1036,7 @@ fn app_tool_policy_allows_per_app_enable_when_default_is_disabled() { Some(&annotations( /*destructive_hint*/ None, /*open_world_hint*/ None, )), + /*managed_approval*/ None, ); assert_eq!( @@ -875,6 +1079,7 @@ fn app_tool_policy_per_tool_enabled_true_overrides_app_level_disable_flags() { "events/create", /*tool_title*/ None, Some(&annotations(Some(true), Some(true))), + /*managed_approval*/ None, ); assert_eq!( @@ -909,6 +1114,7 @@ fn app_tool_policy_default_tools_enabled_true_overrides_app_level_tool_hints() { "events/create", /*tool_title*/ None, Some(&annotations(Some(true), Some(true))), + /*managed_approval*/ None, ); assert_eq!( @@ -945,6 +1151,7 @@ fn app_tool_policy_default_tools_enabled_false_overrides_app_level_tool_hints() Some(&annotations( /*destructive_hint*/ None, /*open_world_hint*/ None, )), + /*managed_approval*/ None, ); assert_eq!( @@ -983,6 +1190,7 @@ fn app_tool_policy_uses_default_tools_approval_mode() { Some(&annotations( /*destructive_hint*/ None, /*open_world_hint*/ None, )), + /*managed_approval*/ None, ); assert_eq!( @@ -1025,6 +1233,7 @@ fn app_tool_policy_matches_prefix_stripped_tool_name_for_tool_config() { "calendar_events/create", Some("events/create"), Some(&annotations(Some(true), Some(true))), + /*managed_approval*/ None, ); assert_eq!( @@ -1141,6 +1350,42 @@ disabled_tools = [ ); } +#[tokio::test] +async fn tool_suggest_uses_connector_id_fallback_when_directory_cache_is_empty() { + let codex_home = tempdir().expect("tempdir should succeed"); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +[features] +apps = true + +[tool_suggest] +discoverables = [ + { type = "connector", id = "connector_gmail" } +] +"#, + ) + .expect("write config"); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .build() + .await + .expect("config should load"); + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + + let discoverable_tools = + list_tool_suggest_discoverable_tools_with_auth(&config, Some(&auth), &[]) + .await + .expect("discoverable tools should load"); + + assert_eq!( + discoverable_tools, + vec![DiscoverableTool::from(plugin_connector_to_app_info( + "connector_gmail".to_string(), + ))] + ); +} + #[test] fn filter_tool_suggest_discoverable_connectors_keeps_only_plugin_backed_uninstalled_apps() { let filtered = filter_tool_suggest_discoverable_connectors( diff --git a/codex-rs/core/src/context/contextual_user_message.rs b/codex-rs/core/src/context/contextual_user_message.rs index cd7788afee..c7ada2317f 100644 --- a/codex-rs/core/src/context/contextual_user_message.rs +++ b/codex-rs/core/src/context/contextual_user_message.rs @@ -5,6 +5,10 @@ use codex_protocol::models::ContentItem; use super::EnvironmentContext; use super::FragmentRegistration; use super::FragmentRegistrationProxy; +use super::GoalContext; +use super::LegacyApplyPatchExecCommandWarning; +use super::LegacyModelMismatchWarning; +use super::LegacyUnifiedExecProcessLimitWarning; use super::SkillInstructions; use super::SubagentNotification; use super::TurnAborted; @@ -23,6 +27,17 @@ static TURN_ABORTED_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); static SUBAGENT_NOTIFICATION_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); +static GOAL_CONTEXT_REGISTRATION: FragmentRegistrationProxy = + FragmentRegistrationProxy::new(); +static LEGACY_UNIFIED_EXEC_PROCESS_LIMIT_WARNING_REGISTRATION: FragmentRegistrationProxy< + LegacyUnifiedExecProcessLimitWarning, +> = FragmentRegistrationProxy::new(); +static LEGACY_APPLY_PATCH_EXEC_COMMAND_WARNING_REGISTRATION: FragmentRegistrationProxy< + LegacyApplyPatchExecCommandWarning, +> = FragmentRegistrationProxy::new(); +static LEGACY_MODEL_MISMATCH_WARNING_REGISTRATION: FragmentRegistrationProxy< + LegacyModelMismatchWarning, +> = FragmentRegistrationProxy::new(); static CONTEXTUAL_USER_FRAGMENTS: &[&dyn FragmentRegistration] = &[ &USER_INSTRUCTIONS_REGISTRATION, @@ -31,6 +46,10 @@ static CONTEXTUAL_USER_FRAGMENTS: &[&dyn FragmentRegistration] = &[ &USER_SHELL_COMMAND_REGISTRATION, &TURN_ABORTED_REGISTRATION, &SUBAGENT_NOTIFICATION_REGISTRATION, + &GOAL_CONTEXT_REGISTRATION, + &LEGACY_UNIFIED_EXEC_PROCESS_LIMIT_WARNING_REGISTRATION, + &LEGACY_APPLY_PATCH_EXEC_COMMAND_WARNING_REGISTRATION, + &LEGACY_MODEL_MISMATCH_WARNING_REGISTRATION, ]; fn is_standard_contextual_user_text(text: &str) -> bool { diff --git a/codex-rs/core/src/context/contextual_user_message_tests.rs b/codex-rs/core/src/context/contextual_user_message_tests.rs index a90b8f280a..ee134cc685 100644 --- a/codex-rs/core/src/context/contextual_user_message_tests.rs +++ b/codex-rs/core/src/context/contextual_user_message_tests.rs @@ -26,6 +26,18 @@ fn detects_subagent_notification_fragment_case_insensitively() { )); } +#[test] +fn detects_goal_context_fragment() { + let text = GoalContext { + prompt: "Continue working toward the active thread goal.".to_string(), + } + .render(); + + assert!(is_contextual_user_fragment(&ContentItem::InputText { + text + })); +} + #[test] fn ignores_regular_user_text() { assert!(!is_contextual_user_fragment(&ContentItem::InputText { diff --git a/codex-rs/core/src/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index c4e77624f8..272e3c617d 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -1,21 +1,88 @@ use crate::session::turn_context::TurnContext; +use crate::session::turn_context::TurnEnvironment; use crate::shell::Shell; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; -use std::path::PathBuf; +use codex_utils_absolute_path::AbsolutePathBuf; use super::ContextualUserFragment; #[derive(Debug, Clone, PartialEq)] pub(crate) struct EnvironmentContext { - pub(crate) cwd: Option, - pub(crate) shell: String, + pub(crate) environments: EnvironmentContextEnvironments, pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) network: Option, pub(crate) subagents: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct EnvironmentContextEnvironment { + pub(crate) id: String, + pub(crate) cwd: AbsolutePathBuf, + pub(crate) shell: String, +} + +impl EnvironmentContextEnvironment { + fn legacy(cwd: AbsolutePathBuf, shell: String) -> Self { + Self { + id: String::new(), + cwd, + shell, + } + } + + fn from_turn_environments(environments: &[TurnEnvironment], shell: &Shell) -> Vec { + environments + .iter() + .map(|environment| Self { + id: environment.environment_id.clone(), + cwd: environment.cwd.clone(), + shell: environment + .shell + .clone() + .unwrap_or_else(|| shell.name().to_string()), + }) + .collect() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum EnvironmentContextEnvironments { + None, + Single(EnvironmentContextEnvironment), + Multiple(Vec), +} + +impl EnvironmentContextEnvironments { + fn from_vec(environments: Vec) -> Self { + let mut environments = environments; + match environments.pop() { + None => Self::None, + Some(environment) if environments.is_empty() => Self::Single(environment), + Some(environment) => { + environments.push(environment); + Self::Multiple(environments) + } + } + } + + fn equals_except_shell(&self, other: &Self) -> bool { + match (self, other) { + (Self::None, Self::None) => true, + (Self::Single(left), Self::Single(right)) => left.cwd == right.cwd, + (Self::Multiple(left), Self::Multiple(right)) => { + left.len() == right.len() + && left + .iter() + .zip(right.iter()) + .all(|(left, right)| left.id == right.id && left.cwd == right.cwd) + } + _ => false, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Default)] pub(crate) struct NetworkContext { allowed_domains: Vec, @@ -29,20 +96,52 @@ impl NetworkContext { denied_domains, } } + + fn render(&self) -> String { + let mut rendered = "".to_string(); + Self::push_rendered_domain_element(&mut rendered, "allowed", &self.allowed_domains); + Self::push_rendered_domain_element(&mut rendered, "denied", &self.denied_domains); + rendered.push_str(""); + rendered + } + + fn push_rendered_domain_element(rendered_network: &mut String, name: &str, domains: &[String]) { + if domains.is_empty() { + return; + } + + rendered_network.push_str(&format!("<{name}>")); + rendered_network.push_str(&domains.join(",")); + rendered_network.push_str(&format!("")); + } } impl EnvironmentContext { pub(crate) fn new( - cwd: Option, - shell: String, + environments: Vec, current_date: Option, timezone: Option, network: Option, subagents: Option, ) -> Self { Self { - cwd, - shell, + environments: EnvironmentContextEnvironments::from_vec(environments), + current_date, + timezone, + network, + subagents, + } + } + + fn new_with_environments( + environments: EnvironmentContextEnvironments, + current_date: Option, + timezone: Option, + network: Option, + subagents: Option, + ) -> Self { + Self { + environments, current_date, timezone, network, @@ -54,19 +153,11 @@ impl EnvironmentContext { /// comparing turn to turn, since the initial environment_context will /// include the shell, and then it is not configurable from turn to turn. pub(crate) fn equals_except_shell(&self, other: &EnvironmentContext) -> bool { - let EnvironmentContext { - cwd, - current_date, - timezone, - network, - subagents, - shell: _, - } = other; - self.cwd == *cwd - && self.current_date == *current_date - && self.timezone == *timezone - && self.network == *network - && self.subagents == *subagents + self.environments.equals_except_shell(&other.environments) + && self.current_date == other.current_date + && self.timezone == other.timezone + && self.network == other.network + && self.subagents == other.subagents } pub(crate) fn diff_from_turn_context_item( @@ -74,18 +165,29 @@ impl EnvironmentContext { after: &EnvironmentContext, ) -> Self { let before_network = Self::network_from_turn_context_item(before); - let cwd = match &after.cwd { - Some(cwd) if before.cwd.as_path() != cwd.as_path() => Some(cwd.clone()), - _ => None, + let environments = match &after.environments { + EnvironmentContextEnvironments::Single(environment) => { + if before.cwd.as_path() != environment.cwd.as_path() { + EnvironmentContextEnvironments::Single(EnvironmentContextEnvironment::legacy( + environment.cwd.clone(), + environment.shell.clone(), + )) + } else { + EnvironmentContextEnvironments::None + } + } + EnvironmentContextEnvironments::Multiple(environments) => { + EnvironmentContextEnvironments::Multiple(environments.clone()) + } + EnvironmentContextEnvironments::None => EnvironmentContextEnvironments::None, }; let network = if before_network != after.network { after.network.clone() } else { before_network }; - EnvironmentContext::new( - cwd, - after.shell.clone(), + EnvironmentContext::new_with_environments( + environments, after.current_date.clone(), after.timezone.clone(), network, @@ -95,8 +197,10 @@ impl EnvironmentContext { pub(crate) fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { Self::new( - Some(turn_context.cwd.to_path_buf()), - shell.name().to_string(), + EnvironmentContextEnvironment::from_turn_environments( + &turn_context.environments.turn_environments, + shell, + ), turn_context.current_date.clone(), turn_context.timezone.clone(), Self::network_from_turn_context(turn_context), @@ -108,9 +212,12 @@ impl EnvironmentContext { turn_context_item: &TurnContextItem, shell: String, ) -> Self { + let cwd = match AbsolutePathBuf::try_from(turn_context_item.cwd.clone()) { + Ok(cwd) => cwd, + Err(_) => AbsolutePathBuf::resolve_path_against_base(&turn_context_item.cwd, "/"), + }; Self::new( - Some(turn_context_item.cwd.clone()), - shell, + vec![EnvironmentContextEnvironment::legacy(cwd, shell)], turn_context_item.current_date.clone(), turn_context_item.timezone.clone(), Self::network_from_turn_context_item(turn_context_item), @@ -168,11 +275,29 @@ impl ContextualUserFragment for EnvironmentContext { fn body(&self) -> String { let mut lines = Vec::new(); - if let Some(cwd) = &self.cwd { - lines.push(format!(" {}", cwd.to_string_lossy())); + match &self.environments { + EnvironmentContextEnvironments::Single(environment) => { + lines.push(format!( + " {}", + environment.cwd.to_string_lossy() + )); + lines.push(format!(" {}", environment.shell)); + } + EnvironmentContextEnvironments::Multiple(environments) => { + lines.push(" ".to_string()); + for environment in environments { + lines.push(format!(" ", environment.id)); + lines.push(format!( + " {}", + environment.cwd.to_string_lossy() + )); + lines.push(format!(" {}", environment.shell)); + lines.push(" ".to_string()); + } + lines.push(" ".to_string()); + } + EnvironmentContextEnvironments::None => {} } - - lines.push(format!(" {}", self.shell)); if let Some(current_date) = &self.current_date { lines.push(format!(" {current_date}")); } @@ -181,14 +306,7 @@ impl ContextualUserFragment for EnvironmentContext { } match &self.network { Some(network) => { - lines.push(" ".to_string()); - for allowed in &network.allowed_domains { - lines.push(format!(" {allowed}")); - } - for denied in &network.denied_domains { - lines.push(format!(" {denied}")); - } - lines.push(" ".to_string()); + lines.push(format!(" {}", network.render())); } None => { // TODO(mbolin): Include this line if it helps the model. diff --git a/codex-rs/core/src/context/environment_context_tests.rs b/codex-rs/core/src/context/environment_context_tests.rs index 84f8c0d99f..68ff7c9d44 100644 --- a/codex-rs/core/src/context/environment_context_tests.rs +++ b/codex-rs/core/src/context/environment_context_tests.rs @@ -1,6 +1,7 @@ use crate::shell::ShellType; use super::*; +use codex_utils_absolute_path::test_support::PathBufExt; use core_test_support::test_path_buf; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -14,12 +15,19 @@ fn fake_shell_name() -> String { shell.name().to_string() } +fn test_abs_path(unix_path: &str) -> AbsolutePathBuf { + test_path_buf(unix_path).abs() +} + #[test] fn serialize_workspace_write_environment_context() { let cwd = test_path_buf("/repo"); let context = EnvironmentContext::new( - Some(cwd.clone()), - fake_shell_name(), + vec![EnvironmentContextEnvironment { + id: "local".to_string(), + cwd: cwd.abs(), + shell: fake_shell_name(), + }], Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), /*network*/ None, @@ -46,8 +54,11 @@ fn serialize_environment_context_with_network() { vec!["blocked.example.com".to_string()], ); let context = EnvironmentContext::new( - Some(test_path_buf("/repo")), - fake_shell_name(), + vec![EnvironmentContextEnvironment { + id: "local".to_string(), + cwd: test_path_buf("/repo").abs(), + shell: fake_shell_name(), + }], Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), Some(network), @@ -60,11 +71,7 @@ fn serialize_environment_context_with_network() { bash 2026-02-26 America/Los_Angeles - - api.example.com - *.openai.com - blocked.example.com - + api.example.com,*.openai.comblocked.example.com "#, test_path_buf("/repo").display() ); @@ -75,8 +82,7 @@ fn serialize_environment_context_with_network() { #[test] fn serialize_read_only_environment_context() { let context = EnvironmentContext::new( - /*cwd*/ None, - fake_shell_name(), + Vec::new(), Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), /*network*/ None, @@ -84,7 +90,6 @@ fn serialize_read_only_environment_context() { ); let expected = r#" - bash 2026-02-26 America/Los_Angeles "#; @@ -95,16 +100,22 @@ fn serialize_read_only_environment_context() { #[test] fn equals_except_shell_compares_cwd() { let context1 = EnvironmentContext::new( - Some(PathBuf::from("/repo")), - fake_shell_name(), + vec![EnvironmentContextEnvironment { + id: "local".to_string(), + cwd: test_abs_path("/repo"), + shell: fake_shell_name(), + }], /*current_date*/ None, /*timezone*/ None, /*network*/ None, /*subagents*/ None, ); let context2 = EnvironmentContext::new( - Some(PathBuf::from("/repo")), - fake_shell_name(), + vec![EnvironmentContextEnvironment { + id: "local".to_string(), + cwd: test_abs_path("/repo"), + shell: fake_shell_name(), + }], /*current_date*/ None, /*timezone*/ None, /*network*/ None, @@ -116,16 +127,22 @@ fn equals_except_shell_compares_cwd() { #[test] fn equals_except_shell_compares_cwd_differences() { let context1 = EnvironmentContext::new( - Some(PathBuf::from("/repo1")), - fake_shell_name(), + vec![EnvironmentContextEnvironment { + id: "local".to_string(), + cwd: test_abs_path("/repo1"), + shell: fake_shell_name(), + }], /*current_date*/ None, /*timezone*/ None, /*network*/ None, /*subagents*/ None, ); let context2 = EnvironmentContext::new( - Some(PathBuf::from("/repo2")), - fake_shell_name(), + vec![EnvironmentContextEnvironment { + id: "local".to_string(), + cwd: test_abs_path("/repo2"), + shell: fake_shell_name(), + }], /*current_date*/ None, /*timezone*/ None, /*network*/ None, @@ -138,16 +155,22 @@ fn equals_except_shell_compares_cwd_differences() { #[test] fn equals_except_shell_ignores_shell() { let context1 = EnvironmentContext::new( - Some(PathBuf::from("/repo")), - "bash".to_string(), + vec![EnvironmentContextEnvironment { + id: "local".to_string(), + cwd: test_abs_path("/repo"), + shell: "bash".to_string(), + }], /*current_date*/ None, /*timezone*/ None, /*network*/ None, /*subagents*/ None, ); let context2 = EnvironmentContext::new( - Some(PathBuf::from("/repo")), - "zsh".to_string(), + vec![EnvironmentContextEnvironment { + id: "other".to_string(), + cwd: test_abs_path("/repo"), + shell: "zsh".to_string(), + }], /*current_date*/ None, /*timezone*/ None, /*network*/ None, @@ -160,8 +183,11 @@ fn equals_except_shell_ignores_shell() { #[test] fn serialize_environment_context_with_subagents() { let context = EnvironmentContext::new( - Some(test_path_buf("/repo")), - fake_shell_name(), + vec![EnvironmentContextEnvironment { + id: "local".to_string(), + cwd: test_path_buf("/repo").abs(), + shell: fake_shell_name(), + }], Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), /*network*/ None, @@ -184,3 +210,91 @@ fn serialize_environment_context_with_subagents() { assert_eq!(context.render(), expected); } + +#[test] +fn serialize_environment_context_with_multiple_selected_environments() { + let local_cwd = test_path_buf("/repo/local"); + let remote_cwd = test_path_buf("/repo/remote"); + let context = EnvironmentContext::new( + vec![ + EnvironmentContextEnvironment { + id: "local".to_string(), + cwd: local_cwd.abs(), + shell: "bash".to_string(), + }, + EnvironmentContextEnvironment { + id: "remote".to_string(), + cwd: remote_cwd.abs(), + shell: "bash".to_string(), + }, + ], + Some("2026-02-26".to_string()), + Some("America/Los_Angeles".to_string()), + /*network*/ None, + /*subagents*/ None, + ); + + let expected = format!( + r#" + + + {} + bash + + + {} + bash + + + 2026-02-26 + America/Los_Angeles +"#, + local_cwd.display(), + remote_cwd.display() + ); + + assert_eq!(context.render(), expected); +} + +#[test] +fn serialize_environment_context_prefers_environment_shell_when_present() { + let local_cwd = test_path_buf("/repo/local"); + let remote_cwd = test_path_buf("/repo/remote"); + let context = EnvironmentContext::new( + vec![ + EnvironmentContextEnvironment { + id: "local".to_string(), + cwd: local_cwd.abs(), + shell: "powershell".to_string(), + }, + EnvironmentContextEnvironment { + id: "remote".to_string(), + cwd: remote_cwd.abs(), + shell: "cmd".to_string(), + }, + ], + /*current_date*/ None, + /*timezone*/ None, + /*network*/ None, + /*subagents*/ None, + ); + + let expected = format!( + r#" + + + {} + powershell + + + {} + cmd + + +"#, + local_cwd.display(), + remote_cwd.display() + ); + + assert_eq!(context.render(), expected); +} diff --git a/codex-rs/core/src/context/goal_context.rs b/codex-rs/core/src/context/goal_context.rs new file mode 100644 index 0000000000..d259273324 --- /dev/null +++ b/codex-rs/core/src/context/goal_context.rs @@ -0,0 +1,18 @@ +//! Hidden user-context fragment for runtime-owned goal steering prompts. + +use super::ContextualUserFragment; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct GoalContext { + pub(crate) prompt: String, +} + +impl ContextualUserFragment for GoalContext { + const ROLE: &'static str = "user"; + const START_MARKER: &'static str = ""; + const END_MARKER: &'static str = ""; + + fn body(&self) -> String { + format!("\n{}\n", self.prompt) + } +} diff --git a/codex-rs/core/src/context/legacy_apply_patch_exec_command_warning.rs b/codex-rs/core/src/context/legacy_apply_patch_exec_command_warning.rs new file mode 100644 index 0000000000..d8fd7da8e9 --- /dev/null +++ b/codex-rs/core/src/context/legacy_apply_patch_exec_command_warning.rs @@ -0,0 +1,21 @@ +use super::ContextualUserFragment; + +// This warning is not produced anymore but fragment definition is used to filter messaged from old sessions +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct LegacyApplyPatchExecCommandWarning; + +impl ContextualUserFragment for LegacyApplyPatchExecCommandWarning { + const ROLE: &'static str = "user"; + const START_MARKER: &'static str = ""; + const END_MARKER: &'static str = ""; + + fn matches_text(text: &str) -> bool { + let trimmed = text.trim(); + trimmed.starts_with("Warning: apply_patch was requested via ") + && trimmed.ends_with("Use the apply_patch tool instead of exec_command.") + } + + fn body(&self) -> String { + String::new() + } +} diff --git a/codex-rs/core/src/context/legacy_model_mismatch_warning.rs b/codex-rs/core/src/context/legacy_model_mismatch_warning.rs new file mode 100644 index 0000000000..a6afa9d7d1 --- /dev/null +++ b/codex-rs/core/src/context/legacy_model_mismatch_warning.rs @@ -0,0 +1,21 @@ +use super::ContextualUserFragment; + +// This warning is not produced anymore but fragment definition is used to filter messaged from old sessions +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct LegacyModelMismatchWarning; + +impl ContextualUserFragment for LegacyModelMismatchWarning { + const ROLE: &'static str = "user"; + const START_MARKER: &'static str = ""; + const END_MARKER: &'static str = ""; + + fn matches_text(text: &str) -> bool { + text.trim().starts_with( + "Warning: Your account was flagged for potentially high-risk cyber activity", + ) + } + + fn body(&self) -> String { + String::new() + } +} diff --git a/codex-rs/core/src/context/legacy_unified_exec_process_limit_warning.rs b/codex-rs/core/src/context/legacy_unified_exec_process_limit_warning.rs new file mode 100644 index 0000000000..463aeaa8aa --- /dev/null +++ b/codex-rs/core/src/context/legacy_unified_exec_process_limit_warning.rs @@ -0,0 +1,21 @@ +use super::ContextualUserFragment; + +// This warning is not produced anymore but fragment definition is used to filter messaged from old sessions +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct LegacyUnifiedExecProcessLimitWarning; + +impl ContextualUserFragment for LegacyUnifiedExecProcessLimitWarning { + const ROLE: &'static str = "user"; + const START_MARKER: &'static str = ""; + const END_MARKER: &'static str = ""; + + fn matches_text(text: &str) -> bool { + text.trim().starts_with( + "Warning: The maximum number of unified exec processes you can keep open is", + ) + } + + fn body(&self) -> String { + String::new() + } +} diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index 25a6e1f134..bd89025368 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -8,9 +8,13 @@ mod collaboration_mode_instructions; mod contextual_user_message; mod environment_context; mod fragment; +mod goal_context; mod guardian_followup_review_reminder; mod hook_additional_context; mod image_generation_instructions; +mod legacy_apply_patch_exec_command_warning; +mod legacy_model_mismatch_warning; +mod legacy_unified_exec_process_limit_warning; mod model_switch_instructions; mod network_rule_saved; mod permissions_instructions; @@ -36,9 +40,13 @@ pub(crate) use environment_context::EnvironmentContext; pub use fragment::ContextualUserFragment; pub(crate) use fragment::FragmentRegistration; pub(crate) use fragment::FragmentRegistrationProxy; +pub(crate) use goal_context::GoalContext; pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder; pub(crate) use hook_additional_context::HookAdditionalContext; pub(crate) use image_generation_instructions::ImageGenerationInstructions; +pub(crate) use legacy_apply_patch_exec_command_warning::LegacyApplyPatchExecCommandWarning; +pub(crate) use legacy_model_mismatch_warning::LegacyModelMismatchWarning; +pub(crate) use legacy_unified_exec_process_limit_warning::LegacyUnifiedExecProcessLimitWarning; pub(crate) use model_switch_instructions::ModelSwitchInstructions; pub(crate) use network_rule_saved::NetworkRuleSaved; pub use permissions_instructions::PermissionsInstructions; diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 5a442bff62..80c057e0eb 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -400,6 +400,7 @@ impl ContextManager { | ResponseItem::ImageGenerationCall { .. } | ResponseItem::CustomToolCall { .. } | ResponseItem::Compaction { .. } + | ResponseItem::ContextCompaction { .. } | ResponseItem::Other => item.clone(), } } @@ -487,7 +488,8 @@ fn is_api_message(message: &ResponseItem) -> bool { | ResponseItem::Reasoning { .. } | ResponseItem::WebSearchCall { .. } | ResponseItem::ImageGenerationCall { .. } - | ResponseItem::Compaction { .. } => true, + | ResponseItem::Compaction { .. } + | ResponseItem::ContextCompaction { .. } => true, ResponseItem::Other => false, } } @@ -535,6 +537,9 @@ pub(crate) fn estimate_response_item_model_visible_bytes(item: &ResponseItem) -> } | ResponseItem::Compaction { encrypted_content: content, + } + | ResponseItem::ContextCompaction { + encrypted_content: Some(content), } => i64::try_from(estimate_reasoning_length(content.len())).unwrap_or(i64::MAX), item => { let raw = serde_json::to_string(item) @@ -681,7 +686,8 @@ fn is_model_generated_item(item: &ResponseItem) -> bool { | ResponseItem::ImageGenerationCall { .. } | ResponseItem::CustomToolCall { .. } | ResponseItem::LocalShellCall { .. } - | ResponseItem::Compaction { .. } => true, + | ResponseItem::Compaction { .. } + | ResponseItem::ContextCompaction { .. } => true, ResponseItem::FunctionCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCallOutput { .. } diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 1bc2cb0895..0cdbc0b010 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -73,6 +73,10 @@ fn build_collaboration_mode_update_item( previous: Option<&TurnContextItem>, next: &TurnContext, ) -> Option { + if !next.config.include_collaboration_mode_instructions { + return None; + } + let prev = previous?; if prev.collaboration_mode.as_ref() != Some(&next.collaboration_mode) { // If the next mode has empty developer instructions, this returns None and we emit no diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index 06f2dcba01..89808c27ee 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -15,16 +15,16 @@ pub(crate) fn default_thread_environment_selections( cwd: &AbsolutePathBuf, ) -> Vec { environment_manager - .default_environment_id() + .default_environment_ids() + .into_iter() .map(|environment_id| TurnEnvironmentSelection { - environment_id: environment_id.to_string(), + environment_id, cwd: cwd.clone(), }) - .into_iter() .collect() } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Default)] pub(crate) struct ResolvedTurnEnvironments { pub(crate) turn_environments: Vec, } @@ -37,17 +37,17 @@ impl ResolvedTurnEnvironments { .collect() } - pub(crate) fn primary_turn_environment(&self) -> Option<&TurnEnvironment> { + pub(crate) fn primary(&self) -> Option<&TurnEnvironment> { self.turn_environments.first() } pub(crate) fn primary_environment(&self) -> Option> { - self.primary_turn_environment() + self.primary() .map(|environment| Arc::clone(&environment.environment)) } pub(crate) fn primary_filesystem(&self) -> Option> { - self.primary_turn_environment() + self.primary() .map(|environment| environment.environment.get_filesystem()) } } @@ -75,6 +75,7 @@ pub(crate) fn resolve_environment_selections( environment_id, environment, cwd: selected_environment.cwd.clone(), + shell: None, }); } @@ -84,6 +85,7 @@ pub(crate) fn resolve_environment_selections( #[cfg(test)] mod tests { use codex_exec_server::ExecServerRuntimePaths; + use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_exec_server::REMOTE_ENVIRONMENT_ID; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_utils_absolute_path::AbsolutePathBuf; @@ -117,6 +119,38 @@ mod tests { ); } + #[tokio::test] + async fn toml_default_thread_environment_selections_include_local_and_remote() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + temp_dir.path().join("environments.toml"), + r#" +[[environments]] +id = "remote" +url = "ws://127.0.0.1:8765" +"#, + ) + .expect("write environments.toml"); + let cwd = AbsolutePathBuf::current_dir().expect("cwd"); + let manager = EnvironmentManager::from_codex_home(temp_dir.path(), test_runtime_paths()) + .await + .expect("environment manager"); + + assert_eq!( + default_thread_environment_selections(&manager, &cwd), + vec![ + TurnEnvironmentSelection { + environment_id: LOCAL_ENVIRONMENT_ID.to_string(), + cwd: cwd.clone(), + }, + TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd, + }, + ] + ); + } + #[tokio::test] async fn default_thread_environment_selections_empty_when_default_disabled() { let cwd = AbsolutePathBuf::current_dir().expect("cwd"); @@ -168,10 +202,11 @@ mod tests { assert_eq!( resolved - .primary_turn_environment() + .primary() .expect("primary environment") .environment_id, "local" ); + assert_eq!(resolved.primary().expect("primary environment").shell, None); } } diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index 85e7034405..a70b2a69b0 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -1,4 +1,6 @@ use super::parse_turn_item; +use crate::context::ContextualUserFragment; +use crate::context::GoalContext; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::HookPromptFragment; use codex_protocol::items::TurnItem; @@ -302,6 +304,23 @@ fn parses_hook_prompt_and_hides_other_contextual_fragments() { } } +#[test] +fn goal_context_does_not_parse_as_visible_turn_item() { + let item = ResponseItem::Message { + id: Some("msg-1".to_string()), + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: GoalContext { + prompt: "Continue working toward the active thread goal.".to_string(), + } + .render(), + }], + phase: None, + }; + + assert!(parse_turn_item(&item).is_none()); +} + #[test] fn parses_agent_message() { let item = ResponseItem::Message { diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index fd5cd7bcdc..a6d958b107 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -97,17 +97,19 @@ pub struct ExecParams { /// Resolved filesystem overrides for the Windows sandbox backends. /// -/// The unelevated restricted-token backend only consumes extra deny-write -/// carveouts on top of the legacy `WorkspaceWrite` allow set. The elevated -/// backend can also consume explicit read and write roots during setup/refresh. -/// Read-root overrides are layered on top of the baseline helper roots that the -/// elevated setup path needs to launch the sandboxed command. Split policies -/// that opt into platform defaults carry that explicitly with the override. +/// The elevated Windows backend consumes extra deny-read paths plus explicit +/// read and write roots during setup/refresh. The unelevated restricted-token +/// backend only consumes extra deny-write carveouts on top of the legacy +/// `WorkspaceWrite` allow set. Read-root overrides are layered on top of the +/// baseline helper roots that the elevated setup path needs to launch the +/// sandboxed command; split policies that opt into platform defaults carry +/// that explicitly with the override. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct WindowsSandboxFilesystemOverrides { pub(crate) read_roots_override: Option>, pub(crate) read_roots_include_platform_defaults: bool, pub(crate) write_roots_override: Option>, + pub(crate) additional_deny_read_paths: Vec, pub(crate) additional_deny_write_paths: Vec, } @@ -564,7 +566,7 @@ async fn exec_windows_sandbox( ) -> Result { use crate::config::find_codex_home; use codex_windows_sandbox::run_windows_sandbox_capture_elevated; - use codex_windows_sandbox::run_windows_sandbox_capture_with_extra_deny_write_paths; + use codex_windows_sandbox::run_windows_sandbox_capture_with_filesystem_overrides; let ExecParams { command, @@ -605,13 +607,10 @@ async fn exec_windows_sandbox( let proxy_enforced = network.is_some(); let use_elevated = windows_sandbox_uses_elevated_backend(sandbox_level, proxy_enforced); let additional_deny_write_paths = windows_sandbox_filesystem_overrides - .map(|overrides| { - overrides - .additional_deny_write_paths - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect::>() - }) + .map(|overrides| overrides.additional_deny_write_paths.clone()) + .unwrap_or_default(); + let additional_deny_read_paths = windows_sandbox_filesystem_overrides + .map(|overrides| overrides.additional_deny_read_paths.clone()) .unwrap_or_default(); let elevated_read_roots_override = windows_sandbox_filesystem_overrides .and_then(|overrides| overrides.read_roots_override.clone()); @@ -619,15 +618,6 @@ async fn exec_windows_sandbox( .is_some_and(|overrides| overrides.read_roots_include_platform_defaults); let elevated_write_roots_override = windows_sandbox_filesystem_overrides .and_then(|overrides| overrides.write_roots_override.clone()); - let elevated_deny_write_paths = windows_sandbox_filesystem_overrides - .map(|overrides| { - overrides - .additional_deny_write_paths - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect::>() - }) - .unwrap_or_default(); let spawn_res = tokio::task::spawn_blocking(move || { if use_elevated { run_windows_sandbox_capture_elevated( @@ -645,11 +635,12 @@ async fn exec_windows_sandbox( read_roots_include_platform_defaults: elevated_read_roots_include_platform_defaults, write_roots_override: elevated_write_roots_override.as_deref(), - deny_write_paths_override: &elevated_deny_write_paths, + deny_read_paths_override: &additional_deny_read_paths, + deny_write_paths_override: &additional_deny_write_paths, }, ) } else { - run_windows_sandbox_capture_with_extra_deny_write_paths( + run_windows_sandbox_capture_with_filesystem_overrides( policy_str.as_str(), &sandbox_cwd, codex_home.as_ref(), @@ -657,6 +648,7 @@ async fn exec_windows_sandbox( &cwd, env, timeout_ms, + &additional_deny_read_paths, &additional_deny_write_paths, windows_sandbox_private_desktop, ) @@ -1049,22 +1041,24 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overrides( )); } - if !file_system_sandbox_policy.has_full_disk_read_access() { + // The restricted-token backend can still enforce split write restrictions, + // but its WRITE_RESTRICTED token does not make capability SID deny-read ACEs + // participate in read access checks. Read restrictions therefore require the + // elevated backend, even when the filesystem root remains readable. + if !windows_policy_has_root_read_access(file_system_sandbox_policy, sandbox_policy_cwd) { return Err( "windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed" .to_string(), ); } - if !file_system_sandbox_policy - .get_unreadable_roots_with_cwd(sandbox_policy_cwd) - .is_empty() - || !file_system_sandbox_policy - .get_unreadable_globs_with_cwd(sandbox_policy_cwd) - .is_empty() - { + let additional_deny_read_paths = codex_windows_sandbox::resolve_windows_deny_read_paths( + file_system_sandbox_policy, + sandbox_policy_cwd, + )?; + if !additional_deny_read_paths.is_empty() { return Err( - "windows unelevated restricted-token sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed" + "windows unelevated restricted-token sandbox cannot enforce deny-read restrictions directly; refusing to run unsandboxed" .to_string(), ); } @@ -1131,7 +1125,7 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overrides( } } - if additional_deny_write_paths.is_empty() { + if additional_deny_read_paths.is_empty() && additional_deny_write_paths.is_empty() { return Ok(None); } @@ -1139,6 +1133,7 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overrides( read_roots_override: None, read_roots_include_platform_defaults: false, write_roots_override: None, + additional_deny_read_paths, additional_deny_write_paths: additional_deny_write_paths .into_iter() .map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string())) @@ -1152,6 +1147,16 @@ fn normalize_windows_override_path(path: &Path) -> std::result::Result bool { + let Some(root) = cwd.as_path().ancestors().last() else { + return false; + }; + file_system_sandbox_policy.can_read_path_with_cwd(root, cwd.as_path()) +} + pub(crate) fn resolve_windows_elevated_filesystem_overrides( sandbox: SandboxType, sandbox_policy: &SandboxPolicy, @@ -1175,18 +1180,10 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides( )); } - if !file_system_sandbox_policy - .get_unreadable_roots_with_cwd(sandbox_policy_cwd) - .is_empty() - || !file_system_sandbox_policy - .get_unreadable_globs_with_cwd(sandbox_policy_cwd) - .is_empty() - { - return Err( - "windows elevated sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed" - .to_string(), - ); - } + let additional_deny_read_paths = codex_windows_sandbox::resolve_windows_deny_read_paths( + file_system_sandbox_policy, + sandbox_policy_cwd, + )?; let split_writable_roots = file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); @@ -1217,7 +1214,13 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides( .collect(); let split_root_path_set: BTreeSet = split_root_paths.iter().cloned().collect(); - let read_roots_override = if file_system_sandbox_policy.has_full_disk_read_access() { + // `has_full_disk_read_access()` is intentionally false when deny-read + // entries exist. For Windows setup overrides, the important question is + // whether the baseline still reads from the filesystem root and only needs + // additional deny ACLs layered on top. + let split_has_root_read_access = + windows_policy_has_root_read_access(file_system_sandbox_policy, sandbox_policy_cwd); + let read_roots_override = if split_has_root_read_access { None } else { Some(split_readable_roots) @@ -1265,6 +1268,7 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides( if read_roots_override.is_none() && write_roots_override.is_none() + && additional_deny_read_paths.is_empty() && additional_deny_write_paths.is_empty() { return Ok(None); @@ -1275,6 +1279,7 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides( && file_system_sandbox_policy.include_platform_defaults(), read_roots_override, write_roots_override, + additional_deny_read_paths, additional_deny_write_paths, })) } diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index 3574ee77fd..a44ffbe3ab 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -649,9 +649,6 @@ pub(crate) fn render_decision_for_unmatched_command( codex_shell_command::is_safe_command::is_safe_powershell_words(command) } }; - if is_known_safe && !used_complex_parsing { - return Decision::Allow; - } // On Windows, ReadOnly sandbox is not a real sandbox, so special-case it // here. @@ -662,6 +659,14 @@ pub(crate) fn render_decision_for_unmatched_command( sandbox_cwd, ); + if is_known_safe + && !used_complex_parsing + && (approval_policy == AskForApproval::UnlessTrusted + || environment_lacks_sandbox_protections) + { + return Decision::Allow; + } + // If the command is flagged as dangerous or we have no sandbox protection, // we should never allow it to run without approval. // diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index 96c32c4491..776aa8e54f 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -1144,6 +1144,29 @@ fn unmatched_on_request_uses_split_filesystem_policy_for_escalation_prompts() { ); } +#[test] +fn known_safe_on_request_still_prompts_for_restricted_sandbox_escalation() { + let command = vec!["echo".to_string(), "hello".to_string()]; + + assert_eq!( + Decision::Prompt, + render_decision_for_unmatched_command( + &command, + UnmatchedCommandContext { + approval_policy: AskForApproval::OnRequest, + permission_profile: &permission_profile_from_sandbox_policy( + &SandboxPolicy::new_workspace_write_policy(), + ), + file_system_sandbox_policy: &workspace_write_file_system_sandbox_policy(), + sandbox_cwd: Path::new("/tmp"), + sandbox_permissions: SandboxPermissions::RequireEscalated, + used_complex_parsing: false, + command_origin: ExecPolicyCommandOrigin::Generic, + }, + ) + ); +} + #[test] fn managed_cwd_write_profile_is_not_read_only() { let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ @@ -1230,6 +1253,55 @@ async fn exec_approval_requirement_prompts_for_inline_additional_permissions_und .await; } +#[tokio::test] +async fn exec_approval_requirement_prompts_for_known_safe_escalation_under_on_request() { + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: vec!["echo".to_string(), "hello".to_string()], + approval_policy: AskForApproval::OnRequest, + sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::RequireEscalated, + prefix_rule: None, + }, + ExecApprovalRequirement::NeedsApproval { + reason: None, + proposed_execpolicy_amendment: Some(ExecPolicyAmendment::new(vec![ + "echo".to_string(), + "hello".to_string(), + ])), + }, + ) + .await; +} + +#[tokio::test] +async fn exec_approval_requirement_rejects_known_safe_escalation_when_granular_sandbox_is_disabled() +{ + assert_exec_approval_requirement_for_command( + ExecApprovalRequirementScenario { + policy_src: None, + command: vec!["echo".to_string(), "hello".to_string()], + approval_policy: AskForApproval::Granular(GranularApprovalConfig { + sandbox_approval: false, + rules: true, + skill_approval: true, + request_permissions: true, + mcp_elicitations: true, + }), + sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + file_system_sandbox_policy: workspace_write_file_system_sandbox_policy(), + sandbox_permissions: SandboxPermissions::RequireEscalated, + prefix_rule: None, + }, + ExecApprovalRequirement::Forbidden { + reason: REJECT_SANDBOX_APPROVAL_REASON.to_string(), + }, + ) + .await; +} + #[tokio::test] async fn exec_approval_requirement_rejects_unmatched_sandbox_escalation_when_granular_sandbox_is_disabled() { @@ -1705,8 +1777,12 @@ async fn proposed_execpolicy_amendment_is_present_when_heuristics_allow() { async fn proposed_execpolicy_amendment_is_suppressed_when_policy_matches_allow() { assert_exec_approval_requirement_for_command( ExecApprovalRequirementScenario { - policy_src: Some(r#"prefix_rule(pattern=["echo"], decision="allow")"#.to_string()), - command: vec!["echo".to_string(), "safe".to_string()], + policy_src: Some(r#"prefix_rule(pattern=["python3"], decision="allow")"#.to_string()), + command: vec![ + "python3".to_string(), + "-c".to_string(), + "print(1)".to_string(), + ], approval_policy: AskForApproval::OnRequest, sandbox_policy: SandboxPolicy::new_read_only_policy(), file_system_sandbox_policy: read_only_file_system_sandbox_policy(), diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index 9d335a81c7..1e802121a1 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -662,11 +662,63 @@ fn windows_restricted_token_supports_full_read_split_write_read_carveouts() { read_roots_override: None, read_roots_include_platform_defaults: false, write_roots_override: None, + additional_deny_read_paths: vec![], additional_deny_write_paths: expected_deny_write_paths, })) ); } +#[test] +fn windows_restricted_token_rejects_unreadable_split_carveouts() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let cwd = dunce::canonicalize(temp_dir.path()) + .expect("canonicalize temp dir") + .abs(); + let blocked = cwd.join("blocked"); + std::fs::create_dir_all(blocked.as_path()).expect("create blocked"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::project_roots( + /*subpath*/ None, + ), + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { path: blocked }, + access: codex_protocol::permissions::FileSystemAccessMode::None, + }, + ]); + + assert_eq!( + resolve_windows_restricted_token_filesystem_overrides( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + &cwd, + WindowsSandboxLevel::RestrictedToken, + ), + Err( + "windows unelevated restricted-token sandbox cannot enforce deny-read restrictions directly; refusing to run unsandboxed" + .to_string() + ) + ); +} + #[test] fn windows_elevated_supports_split_restricted_read_roots() { let temp_dir = tempfile::TempDir::new().expect("tempdir"); @@ -699,6 +751,7 @@ fn windows_elevated_supports_split_restricted_read_roots() { read_roots_override: Some(vec![expected_docs]), read_roots_include_platform_defaults: false, write_roots_override: None, + additional_deny_read_paths: vec![], additional_deny_write_paths: vec![], })) ); @@ -753,6 +806,7 @@ fn windows_elevated_supports_split_write_read_carveouts() { read_roots_override: None, read_roots_include_platform_defaults: false, write_roots_override: None, + additional_deny_read_paths: vec![], additional_deny_write_paths: vec![ codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(expected_docs) .expect("absolute docs"), @@ -762,10 +816,11 @@ fn windows_elevated_supports_split_write_read_carveouts() { } #[test] -fn windows_elevated_rejects_unreadable_split_carveouts() { +fn windows_elevated_supports_unreadable_split_carveouts() { let temp_dir = tempfile::TempDir::new().expect("tempdir"); let blocked = temp_dir.path().join("blocked"); std::fs::create_dir_all(&blocked).expect("create blocked"); + let expected_blocked = dunce::canonicalize(&blocked).expect("canonical blocked"); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], network_access: false, @@ -797,24 +852,38 @@ fn windows_elevated_rejects_unreadable_split_carveouts() { ]); assert_eq!( - unsupported_windows_restricted_token_sandbox_reason( + resolve_windows_elevated_filesystem_overrides( SandboxType::WindowsRestrictedToken, &policy, &file_system_policy, NetworkSandboxPolicy::Restricted, &temp_dir.path().abs(), - WindowsSandboxLevel::Elevated, + /*use_windows_elevated_backend*/ true, ), - Some( - "windows elevated sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed" - .to_string() - ) + Ok(Some(WindowsSandboxFilesystemOverrides { + read_roots_override: None, + read_roots_include_platform_defaults: false, + write_roots_override: None, + additional_deny_read_paths: vec![ + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path( + expected_blocked.clone(), + ) + .expect("absolute blocked"), + ], + additional_deny_write_paths: vec![ + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(expected_blocked) + .expect("absolute blocked"), + ], + })) ); } #[test] -fn windows_elevated_rejects_unreadable_globs() { +fn windows_elevated_supports_unreadable_globs() { let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let secret = temp_dir.path().join("app").join(".env"); + std::fs::create_dir_all(secret.parent().expect("parent")).expect("create parent"); + std::fs::write(&secret, "secret").expect("write secret"); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], network_access: false, @@ -845,18 +914,24 @@ fn windows_elevated_rejects_unreadable_globs() { ]); assert_eq!( - unsupported_windows_restricted_token_sandbox_reason( + resolve_windows_elevated_filesystem_overrides( SandboxType::WindowsRestrictedToken, &policy, &file_system_policy, NetworkSandboxPolicy::Restricted, &temp_dir.path().abs(), - WindowsSandboxLevel::Elevated, + /*use_windows_elevated_backend*/ true, ), - Some( - "windows elevated sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed" - .to_string() - ) + Ok(Some(WindowsSandboxFilesystemOverrides { + read_roots_override: None, + read_roots_include_platform_defaults: false, + write_roots_override: None, + additional_deny_read_paths: vec![ + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(secret) + .expect("absolute secret"), + ], + additional_deny_write_paths: vec![], + })) ); } diff --git a/codex-rs/core/src/flags.rs b/codex-rs/core/src/flags.rs deleted file mode 100644 index de693868df..0000000000 --- a/codex-rs/core/src/flags.rs +++ /dev/null @@ -1,6 +0,0 @@ -use env_flags::env_flags; - -env_flags! { - /// Fixture path for offline tests (see client.rs). - pub CODEX_RS_SSE_FIXTURE: Option<&str> = None; -} diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index f1805bb750..4a7a9a3d0b 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -5,13 +5,21 @@ //! events, and owns helper hooks used by goal lifecycle behavior. use crate::StateDbHandle; +use crate::context::ContextualUserFragment; +use crate::context::GoalContext; use crate::session::session::Session; use crate::session::turn_context::TurnContext; use crate::state::ActiveTurn; use crate::state::TurnState; use crate::tasks::RegularTask; +use crate::tools::handlers::goal_spec::UPDATE_GOAL_TOOL_NAME; use anyhow::Context; use codex_features::Feature; +use codex_otel::GOAL_BUDGET_LIMITED_METRIC; +use codex_otel::GOAL_COMPLETED_METRIC; +use codex_otel::GOAL_CREATED_METRIC; +use codex_otel::GOAL_DURATION_SECONDS_METRIC; +use codex_otel::GOAL_TOKEN_COUNT_METRIC; use codex_protocol::config_types::ModeKind; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; @@ -62,12 +70,65 @@ static BUDGET_LIMIT_PROMPT_TEMPLATE: LazyLock