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/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/workflows/bazel.yml b/.github/workflows/bazel.yml index 082434de0b..cc3968d306 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -57,6 +57,9 @@ jobs: 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: Check rusty_v8 MODULE.bazel checksums if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu' @@ -149,6 +152,9 @@ jobs: 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 @@ -232,6 +238,9 @@ jobs: 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 @@ -319,6 +328,9 @@ jobs: 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 diff --git a/.github/workflows/blob-size-policy.yml b/.github/workflows/blob-size-policy.yml index e7cd67072f..779198ee02 100644 --- a/.github/workflows/blob-size-policy.yml +++ b/.github/workflows/blob-size-policy.yml @@ -10,15 +10,17 @@ jobs: steps: - 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 024198b8d1..f20d09e112 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -15,6 +15,9 @@ jobs: steps: - name: Checkout 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 1a6ce7058e..a1c60acc26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,9 @@ jobs: 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: Verify codex-rs Cargo manifests inherit workspace settings run: python3 .github/scripts/verify_cargo_workspace_manifests.py diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index 52c9438b38..aaa15cf40d 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -19,6 +19,9 @@ jobs: steps: - name: Checkout 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.1.0 - name: Codespell diff --git a/.github/workflows/issue-deduplicator.yml b/.github/workflows/issue-deduplicator.yml index 66148f09b8..11b4e914fe 100644 --- a/.github/workflows/issue-deduplicator.yml +++ b/.github/workflows/issue-deduplicator.yml @@ -20,6 +20,8 @@ jobs: has_matches: ${{ steps.normalize-all.outputs.has_matches }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Prepare Codex inputs env: @@ -156,6 +158,8 @@ jobs: has_matches: ${{ steps.normalize-open.outputs.has_matches }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Prepare Codex inputs env: diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index 5ae456633c..a0bba631cd 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -18,6 +18,8 @@ jobs: codex_output: ${{ steps.codex.outputs.final-message }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - id: codex uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02 # v1.7 diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index d0edf6751b..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 --------------------------------- @@ -18,6 +23,8 @@ jobs: working-directory: codex-rs steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 with: components: rustfmt @@ -32,13 +39,14 @@ jobs: working-directory: codex-rs steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 with: - tool: cargo-shear - version: 1.11.2 + 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 @@ -48,6 +56,8 @@ jobs: DYLINT_LINK_VERSION: 5.0.0 steps: - 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 @@ -98,6 +108,8 @@ jobs: labels: codex-windows-x64 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: ./.github/actions/setup-bazel-ci with: target: ${{ runner.os }} @@ -234,6 +246,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Install Linux build dependencies if: ${{ runner.os == 'Linux' }} shell: bash @@ -560,6 +574,8 @@ jobs: steps: - 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. @@ -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 b771e42b27..75c5c33601 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -16,7 +16,9 @@ jobs: steps: - 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 @@ -62,6 +64,9 @@ jobs: working-directory: codex-rs 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 - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 with: components: rustfmt @@ -78,13 +83,15 @@ jobs: working-directory: codex-rs 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 - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 with: - tool: cargo-shear - version: 1.11.2 + 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 @@ -96,6 +103,9 @@ jobs: DYLINT_LINK_VERSION: 5.0.0 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 - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 - name: Install nightly argument-comment-lint toolchain shell: bash @@ -172,6 +182,9 @@ jobs: echo "run=false" >> "$GITHUB_OUTPUT" - 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 277dcd122a..f654bd9dd7 100644 --- a/.github/workflows/rust-release-argument-comment-lint.yml +++ b/.github/workflows/rust-release-argument-comment-lint.yml @@ -57,6 +57,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 with: diff --git a/.github/workflows/rust-release-prepare.yml b/.github/workflows/rust-release-prepare.yml index b478344a2c..e998efb54c 100644 --- a/.github/workflows/rust-release-prepare.yml +++ b/.github/workflows/rust-release-prepare.yml @@ -22,6 +22,7 @@ jobs: with: ref: main fetch-depth: 0 + persist-credentials: false - name: Update models.json env: diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 24d6d67e03..8a1b6030a8 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -84,6 +84,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Print runner specs (Windows) shell: powershell run: | @@ -166,6 +168,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Download prebuilt Windows primary binaries uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -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 diff --git a/.github/workflows/rust-release-zsh.yml b/.github/workflows/rust-release-zsh.yml index 838fe0c282..492b8dc5e7 100644 --- a/.github/workflows/rust-release-zsh.yml +++ b/.github/workflows/rust-release-zsh.yml @@ -46,6 +46,8 @@ jobs: libncursesw5-dev - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Build, smoke-test, and stage zsh artifact shell: bash @@ -82,6 +84,8 @@ jobs: fi - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Build, smoke-test, and stage zsh artifact shell: bash diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 43d70e5a3f..6dd751b7f6 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -20,6 +20,8 @@ jobs: runs-on: ubuntu-latest steps: - 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 @@ -119,6 +121,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Print runner specs (Linux) if: ${{ runner.os == 'Linux' }} shell: bash @@ -184,6 +188,7 @@ jobs: 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 @@ -394,6 +399,65 @@ jobs: 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: | @@ -473,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.0.2 + with: + persist-credentials: false - name: Generate release notes from tag commit message id: release_notes @@ -547,6 +614,22 @@ 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: @@ -780,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 421346aae1..5a436722bb 100644 --- a/.github/workflows/rusty-v8-release.yml +++ b/.github/workflows/rusty-v8-release.yml @@ -18,6 +18,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 @@ -70,6 +72,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Set up Bazel uses: ./.github/actions/setup-bazel-ci diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index 861eb7a095..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 @@ -14,6 +49,9 @@ jobs: 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: Install Linux bwrap build dependencies shell: bash diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index a1aeaf4029..6e71367d1f 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -41,6 +41,9 @@ jobs: 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: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 @@ -75,6 +78,9 @@ jobs: 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: Set up Bazel uses: ./.github/actions/setup-bazel-ci 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/MODULE.bazel.lock b/MODULE.bazel.lock index e079e3af0e..3174ce5ecd 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\"]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 19c5d45415..58da81e835 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" @@ -1867,12 +1895,16 @@ dependencies = [ "codex-core", "codex-core-plugins", "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", @@ -1890,6 +1922,7 @@ dependencies = [ "codex-state", "codex-thread-store", "codex-tools", + "codex-uds", "codex-utils-absolute-path", "codex-utils-cargo-bin", "codex-utils-cli", @@ -1898,16 +1931,13 @@ dependencies = [ "core_test_support", "flate2", "futures", - "hmac", "opentelemetry", "opentelemetry_sdk", "pretty_assertions", - "reqwest", "rmcp", "serde", "serde_json", "serial_test", - "sha2", "shlex", "tar", "tempfile", @@ -1951,6 +1981,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" @@ -2003,11 +2054,8 @@ dependencies = [ name = "codex-app-server-transport" version = "0.0.0" dependencies = [ - "anyhow", - "axum", "base64 0.22.1", "chrono", - "clap", "codex-api", "codex-app-server-protocol", "codex-config", @@ -2018,18 +2066,12 @@ dependencies = [ "codex-uds", "codex-utils-absolute-path", "codex-utils-rustls-provider", - "constant_time_eq 0.3.1", "futures", "gethostname", - "hmac", - "jsonwebtoken", - "owo-colors", "pretty_assertions", "serde", "serde_json", - "sha2", "tempfile", - "time", "tokio", "tokio-tungstenite", "tokio-util", @@ -2125,17 +2167,6 @@ dependencies = [ "serde_with", ] -[[package]] -name = "codex-builtin-mcps" -version = "0.0.0" -dependencies = [ - "anyhow", - "codex-memories-mcp", - "codex-utils-absolute-path", - "pretty_assertions", - "tokio", -] - [[package]] name = "codex-bwrap" version = "0.0.0" @@ -2178,6 +2209,7 @@ dependencies = [ "clap", "clap_complete", "codex-app-server", + "codex-app-server-daemon", "codex-app-server-protocol", "codex-app-server-test-client", "codex-arg0", @@ -2413,7 +2445,11 @@ dependencies = [ "codex-app-server-protocol", "pretty_assertions", "serde", + "serde_json", + "sha1", + "tempfile", "tokio", + "tracing", "urlencoding", ] @@ -2443,6 +2479,7 @@ dependencies = [ "codex-core-skills", "codex-exec-server", "codex-execpolicy", + "codex-extension-api", "codex-features", "codex-feedback", "codex-git-utils", @@ -2468,6 +2505,7 @@ dependencies = [ "codex-terminal-detection", "codex-test-binary-support", "codex-thread-store", + "codex-tool-api", "codex-tools", "codex-utils-absolute-path", "codex-utils-cache", @@ -2498,7 +2536,6 @@ dependencies = [ "insta", "libc", "maplit", - "notify", "once_cell", "openssl-sys", "opentelemetry", @@ -2547,6 +2584,7 @@ dependencies = [ "codex-config", "codex-core", "codex-exec-server", + "codex-extension-api", "codex-features", "codex-login", "codex-model-provider-info", @@ -2688,6 +2726,7 @@ dependencies = [ "anyhow", "arc-swap", "async-trait", + "axum", "base64 0.22.1", "bytes", "codex-app-server-protocol", @@ -2698,6 +2737,7 @@ dependencies = [ "codex-test-binary-support", "codex-utils-absolute-path", "codex-utils-pty", + "codex-utils-rustls-provider", "ctor 0.6.3", "futures", "pretty_assertions", @@ -2705,13 +2745,13 @@ dependencies = [ "serde", "serde_json", "serial_test", - "sha2", "tempfile", "test-case", "thiserror 2.0.18", "tokio", "tokio-tungstenite", "tokio-util", + "toml 0.9.11+spec-1.1.0", "tracing", "uuid", "wiremock", @@ -2763,6 +2803,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" @@ -2841,6 +2889,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" @@ -2865,6 +2935,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" @@ -2992,7 +3071,6 @@ dependencies = [ "async-channel", "codex-api", "codex-async-utils", - "codex-builtin-mcps", "codex-client", "codex-config", "codex-exec-server", @@ -3027,6 +3105,7 @@ dependencies = [ "codex-config", "codex-core", "codex-exec-server", + "codex-extension-api", "codex-login", "codex-protocol", "codex-shell-command", @@ -3303,7 +3382,6 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-image", "codex-utils-string", - "codex-utils-template", "encoding_rs", "globset", "http 1.4.0", @@ -3437,6 +3515,7 @@ dependencies = [ "anyhow", "codex-code-mode", "codex-protocol", + "http 1.4.0", "pretty_assertions", "serde", "serde_json", @@ -3610,20 +3689,24 @@ dependencies = [ "codex-rollout", "codex-state", "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" @@ -3720,6 +3803,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "sha2", "shlex", "strum 0.27.2", "strum_macros 0.28.0", @@ -4240,6 +4324,7 @@ dependencies = [ "codex-config", "codex-core", "codex-exec-server", + "codex-extension-api", "codex-features", "codex-hooks", "codex-login", @@ -8898,7 +8983,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", @@ -9366,7 +9451,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]] @@ -14010,7 +14095,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 6bda741c9c..5a21e98995 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -5,12 +5,12 @@ members = [ "agent-graph-store", "agent-identity", "backend-client", - "builtin-mcps", "bwrap", "ansi-escape", "async-utils", "app-server", "app-server-transport", + "app-server-daemon", "app-server-client", "app-server-protocol", "app-server-test-client", @@ -44,10 +44,14 @@ 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", @@ -104,6 +108,7 @@ members = [ "test-binary-support", "thread-manager-sample", "thread-store", + "tool-api", "uds", "codex-experimental-api-macros", "plugin", @@ -131,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" } @@ -138,7 +144,6 @@ codex-apply-patch = { path = "apply-patch" } codex-arg0 = { path = "arg0" } codex-async-utils = { path = "async-utils" } codex-backend-client = { path = "backend-client" } -codex-builtin-mcps = { path = "builtin-mcps" } codex-chatgpt = { path = "chatgpt" } codex-cli = { path = "cli" } codex-client = { path = "codex-client" } @@ -157,6 +162,9 @@ 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" } @@ -164,6 +172,7 @@ 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" } @@ -171,7 +180,6 @@ codex-linux-sandbox = { path = "linux-sandbox" } codex-lmstudio = { path = "lmstudio" } codex-login = { path = "login" } codex-message-history = { path = "message-history" } -codex-memories-mcp = { path = "memories/mcp" } codex-memories-read = { path = "memories/read" } codex-memories-write = { path = "memories/write" } codex-mcp = { path = "codex-mcp" } @@ -201,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" } @@ -257,7 +266,6 @@ chrono = "0.4.43" clap = "4" clap_complete = "4" color-eyre = "0.6.3" -constant_time_eq = "0.3.1" crossbeam-channel = "0.5.15" crypto_box = { version = "0.9.1", features = ["seal"] } crossterm = "0.28.1" @@ -464,11 +472,8 @@ unwrap_used = "deny" [workspace.metadata.cargo-shear] ignored = [ "codex-agent-graph-store", - "codex-memories-mcp", "icu_provider", "openssl-sys", - "codex-utils-readiness", - "codex-utils-template", "codex-v8-poc", ] 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 880adfc254..5b4a72229b 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -1,5 +1,7 @@ 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; @@ -9,18 +11,25 @@ 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::ToolItemFinalApprovalOutcome; use crate::events::ToolItemTerminalStatus; use crate::events::TrackEventRequest; use crate::events::codex_app_metadata; @@ -66,18 +75,35 @@ 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; @@ -89,6 +115,7 @@ 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; @@ -105,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; @@ -115,6 +143,9 @@ 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; @@ -603,7 +634,7 @@ async fn ingest_turn_prerequisites( } } -async fn ingest_tool_review_prerequisites( +async fn ingest_review_prerequisites( reducer: &mut AnalyticsReducer, events: &mut Vec, ) { @@ -625,6 +656,58 @@ async fn ingest_tool_review_prerequisites( 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, @@ -636,6 +719,7 @@ fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact { }, capabilities: Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), }, @@ -654,9 +738,18 @@ 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: "item-1".to_string(), + id: id.to_string(), command: "echo hi".to_string(), cwd: test_path_buf("/tmp").abs(), process_id: Some("pid-1".to_string()), @@ -687,6 +780,98 @@ fn sample_command_execution_item_with_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()) @@ -827,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 { @@ -1023,7 +1388,7 @@ fn command_execution_event_serializes_expected_shape() { review_count: 0, guardian_review_count: 0, user_review_count: 0, - final_approval_outcome: ToolItemFinalApprovalOutcome::NotNeeded, + final_approval_outcome: FinalApprovalOutcome::NotNeeded, terminal_status: ToolItemTerminalStatus::Completed, failure_kind: None, requested_additional_permissions: false, @@ -1089,6 +1454,82 @@ fn command_execution_event_serializes_expected_shape() { ); } +#[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(); @@ -1122,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, }), }, @@ -1269,6 +1711,7 @@ async fn compaction_event_ingests_custom_fact() { }, capabilities: Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), }, @@ -1382,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, }), }, @@ -1500,7 +1944,15 @@ async fn item_lifecycle_notifications_publish_command_execution_event() { let mut reducer = AnalyticsReducer::default(); let mut events = Vec::new(); - ingest_tool_review_prerequisites(&mut reducer, &mut events).await; + 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( @@ -1603,6 +2055,336 @@ async fn item_lifecycle_notifications_publish_command_execution_event() { 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( @@ -1891,7 +2673,7 @@ async fn subagent_tool_items_inherit_parent_connection_metadata() { let mut reducer = AnalyticsReducer::default(); let mut events = Vec::new(); - ingest_tool_review_prerequisites(&mut reducer, &mut events).await; + ingest_review_prerequisites(&mut reducer, &mut events).await; reducer .ingest( AnalyticsFact::Custom(CustomAnalyticsFact::SubAgentThreadStarted( @@ -1911,6 +2693,15 @@ async fn subagent_tool_items_inherit_parent_connection_metadata() { ) .await; events.clear(); + reducer + .ingest( + AnalyticsFact::Notification(Box::new(sample_turn_started_notification( + "thread-subagent", + "turn-subagent", + ))), + &mut events, + ) + .await; reducer .ingest( @@ -2785,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)); @@ -2798,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 6d6d446560..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)), ))); } @@ -340,17 +343,39 @@ 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(_) @@ -370,6 +395,7 @@ async fn send_track_events( if events.is_empty() { return; } + let Some(auth) = auth_manager.auth().await else { return; }; @@ -379,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 3021d558d6..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 { @@ -222,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 23afd83b75..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,7 +19,7 @@ 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; @@ -70,6 +71,7 @@ pub(crate) enum TrackEventRequest { CollabAgentToolCall(CodexCollabAgentToolCallEventRequest), WebSearch(CodexWebSearchEventRequest), ImageGeneration(CodexImageGenerationEventRequest), + AcceptedLineFingerprints(Box), #[allow(dead_code)] ReviewEvent(CodexReviewEventRequest), PluginUsed(CodexPluginUsedEventRequest), @@ -79,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, @@ -261,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, } @@ -283,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(), } } @@ -291,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(), @@ -316,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 @@ -400,7 +429,7 @@ pub(crate) struct GuardianReviewEventPayload { #[allow(dead_code)] #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum ToolItemFinalApprovalOutcome { +pub(crate) enum FinalApprovalOutcome { Unknown, NotNeeded, ConfigAllowed, @@ -457,7 +486,7 @@ pub(crate) struct CodexToolItemEventBase { pub(crate) review_count: u64, pub(crate) guardian_review_count: u64, pub(crate) user_review_count: u64, - pub(crate) final_approval_outcome: ToolItemFinalApprovalOutcome, + pub(crate) final_approval_outcome: FinalApprovalOutcome, pub(crate) terminal_status: ToolItemTerminalStatus, pub(crate) failure_kind: Option, pub(crate) requested_additional_permissions: bool, @@ -524,8 +553,8 @@ pub(crate) struct CodexReviewEventParams { pub(crate) thread_source: Option, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, - pub(crate) tool_kind: ReviewSubjectKind, - pub(crate) tool_name: String, + pub(crate) subject_kind: ReviewSubjectKind, + pub(crate) subject_name: String, pub(crate) reviewer: Reviewer, pub(crate) trigger: ReviewTrigger, pub(crate) status: ReviewStatus, @@ -765,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, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 861e6534a2..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, @@ -296,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 2fb23199cb..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; diff --git a/codex-rs/analytics/src/reducer.rs b/codex-rs/analytics/src/reducer.rs index d35fb96602..d072720d10 100644 --- a/codex-rs/analytics/src/reducer.rs +++ b/codex-rs/analytics/src/reducer.rs @@ -1,3 +1,7 @@ +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; @@ -18,6 +22,8 @@ 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; @@ -26,15 +32,20 @@ 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::ToolItemFinalApprovalOutcome; use crate::events::ToolItemTerminalStatus; use crate::events::TrackEventRequest; use crate::events::WebSearchActionKind; @@ -76,16 +87,24 @@ 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; @@ -101,9 +120,12 @@ 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 { @@ -112,6 +134,8 @@ pub(crate) struct AnalyticsReducer { connections: HashMap, threads: HashMap, tool_items_started_at_ms: HashMap, + pending_reviews: HashMap, + item_review_summaries: HashMap, } struct ConnectionState { @@ -145,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", @@ -195,6 +229,30 @@ 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, @@ -264,7 +322,9 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + latest_diff: Option, steer_count: usize, + tool_counts: TurnToolCounts, } #[derive(Hash, Eq, PartialEq)] @@ -274,6 +334,42 @@ struct ToolItemKey { 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 { pub(crate) async fn ingest(&mut self, input: AnalyticsFact, out: &mut Vec) { match input { @@ -305,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 { @@ -317,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); @@ -337,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; @@ -472,7 +591,7 @@ impl AnalyticsReducer { } } - fn ingest_turn_resolved_config( + async fn ingest_turn_resolved_config( &mut self, input: TurnResolvedConfigFact, out: &mut Vec, @@ -488,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, @@ -510,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( @@ -621,7 +744,7 @@ impl AnalyticsReducer { }); } - fn ingest_response( + async fn ingest_response( &mut self, connection_id: u64, response: ClientResponse, @@ -673,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, @@ -690,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, @@ -740,7 +1066,7 @@ impl AnalyticsReducer { ); } - fn ingest_notification( + async fn ingest_notification( &mut self, notification: ServerNotification, out: &mut Vec, @@ -767,6 +1093,16 @@ impl AnalyticsReducer { 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(), @@ -790,17 +1126,25 @@ impl AnalyticsReducer { else { return; }; - if let Some(event) = tool_item_event( - ¬ification.thread_id, - ¬ification.turn_id, - ¬ification.item, + 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 { @@ -811,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 @@ -830,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), @@ -849,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; } _ => {} } @@ -920,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, @@ -985,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; }; @@ -1018,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); } @@ -1116,21 +1597,41 @@ fn tracked_tool_item_id(item: &ThreadItem) -> Option<&str> { } } -fn tool_item_event( - thread_id: &str, - turn_id: &str, - item: &ThreadItem, +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: &ConnectionState, - thread_metadata: &ThreadMetadataState, -) -> Option { - let context = ToolItemContext { + 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, @@ -1153,7 +1654,13 @@ fn tool_item_event( failure_kind, execution_duration_ms: option_i64_to_u64(*duration_ms), }, - context, + ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + review_summary, + }, ); Some(TrackEventRequest::CommandExecution( CodexCommandExecutionEventRequest { @@ -1188,7 +1695,13 @@ fn tool_item_event( failure_kind, execution_duration_ms: None, }, - context, + ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + review_summary, + }, ); Some(TrackEventRequest::FileChange(CodexFileChangeEventRequest { event_type: "codex_file_change_event", @@ -1222,7 +1735,13 @@ fn tool_item_event( failure_kind, execution_duration_ms: option_i64_to_u64(*duration_ms), }, - context, + ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + review_summary, + }, ); Some(TrackEventRequest::McpToolCall( CodexMcpToolCallEventRequest { @@ -1259,7 +1778,13 @@ fn tool_item_event( failure_kind, execution_duration_ms: option_i64_to_u64(*duration_ms), }, - context, + ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + review_summary, + }, ); Some(TrackEventRequest::DynamicToolCall( CodexDynamicToolCallEventRequest { @@ -1297,7 +1822,13 @@ fn tool_item_event( failure_kind, execution_duration_ms: None, }, - context, + ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + review_summary, + }, ); Some(TrackEventRequest::CollabAgentToolCall( CodexCollabAgentToolCallEventRequest { @@ -1346,7 +1877,13 @@ fn tool_item_event( failure_kind: None, execution_duration_ms: None, }, - context, + ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + review_summary, + }, ); Some(TrackEventRequest::WebSearch(CodexWebSearchEventRequest { event_type: "codex_web_search_event", @@ -1376,7 +1913,13 @@ fn tool_item_event( failure_kind, execution_duration_ms: None, }, - context, + ToolItemContext { + started_at_ms, + completed_at_ms, + connection_state, + thread_metadata, + review_summary, + }, ); Some(TrackEventRequest::ImageGeneration( CodexImageGenerationEventRequest { @@ -1430,6 +1973,7 @@ struct ToolItemContext<'a> { completed_at_ms: u64, connection_state: &'a ConnectionState, thread_metadata: &'a ThreadMetadataState, + review_summary: Option<&'a ItemReviewSummary>, } fn tool_item_base( @@ -1441,6 +1985,7 @@ fn tool_item_base( 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(), @@ -1458,14 +2003,16 @@ fn tool_item_base( // 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: 0, - guardian_review_count: 0, - user_review_count: 0, - final_approval_outcome: ToolItemFinalApprovalOutcome::Unknown, + 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: false, - requested_network_access: false, + requested_additional_permissions: review_summary.requested_additional_permissions, + requested_network_access: review_summary.requested_network_access, } } @@ -1473,6 +2020,195 @@ 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 @@ -1641,6 +2377,36 @@ fn web_search_query_count(query: &str, action: Option<&WebSearchAction>) -> Opti } } +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, @@ -1711,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), @@ -1872,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 fee29db47e..ac284cbdfe 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 diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index ebafe351af..3b386ff3ce 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -49,7 +49,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; @@ -375,6 +374,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 { diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index d75534c160..4a4426a260 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -73,6 +73,7 @@ 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 { 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..64228d9c9b --- /dev/null +++ b/codex-rs/app-server-daemon/src/backend/pid.rs @@ -0,0 +1,600 @@ +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![ + "--enable", + "remote_control", + "app-server", + "--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/AttestationGenerateResponse.json b/codex-rs/app-server-protocol/schema/json/AttestationGenerateResponse.json new file mode 100644 index 0000000000..e6bd59ec25 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/AttestationGenerateResponse.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "token": { + "description": "Opaque client attestation token.", + "type": "string" + } + }, + "required": [ + "token" + ], + "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 cac1c33d9b..6351993046 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1259,6 +1259,11 @@ "array", "null" ] + }, + "requestAttestation": { + "default": false, + "description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.", + "type": "boolean" } }, "type": "object" @@ -2083,16 +2088,37 @@ }, "principalType": { "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginShareTargetRole" } }, "required": [ "principalId", - "principalType" + "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" }, @@ -2104,6 +2130,7 @@ } }, "required": [ + "discoverability", "remotePluginId", "shareTargets" ], @@ -3212,24 +3239,6 @@ ], "type": "object" }, - "SkillsListExtraRootsForCwd": { - "properties": { - "cwd": { - "type": "string" - }, - "extraUserRoots": { - "items": { - "type": "string" - }, - "type": "array" - } - }, - "required": [ - "cwd", - "extraUserRoots" - ], - "type": "object" - }, "SkillsListParams": { "properties": { "cwds": { @@ -3242,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" @@ -4096,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": { @@ -6181,4 +6204,4 @@ } ], "title": "ClientRequest" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json b/codex-rs/app-server-protocol/schema/json/CommandExecutionRequestApprovalParams.json index 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 5dc3c09a44..6af19f89e2 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1963,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" }, @@ -1973,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": [ @@ -1989,9 +1999,11 @@ }, "required": [ "action", + "completedAtMs", "decisionSource", "review", "reviewId", + "startedAtMs", "threadId", "turnId" ], @@ -2010,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": [ @@ -2028,6 +2045,7 @@ "action", "review", "reviewId", + "startedAtMs", "threadId", "turnId" ], @@ -2719,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": [ @@ -2727,11 +2745,15 @@ "null" ] }, + "installationId": { + "type": "string" + }, "status": { "$ref": "#/definitions/RemoteControlConnectionStatus" } }, "required": [ + "installationId", "status" ], "type": "object" 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 5e24845729..9934e36940 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": { @@ -2146,6 +2165,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -2155,6 +2179,7 @@ }, "required": [ "itemId", + "startedAtMs", "threadId", "turnId" ], @@ -2411,6 +2436,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -2420,6 +2450,7 @@ }, "required": [ "itemId", + "startedAtMs", "threadId", "turnId" ], @@ -2608,6 +2639,11 @@ "array", "null" ] + }, + "requestAttestation": { + "default": false, + "description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.", + "type": "boolean" } }, "type": "object" @@ -3591,6 +3627,11 @@ "null" ] }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, "threadId": { "type": "string" }, @@ -3602,6 +3643,7 @@ "cwd", "itemId", "permissions", + "startedAtMs", "threadId", "turnId" ], @@ -5189,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": { @@ -7657,6 +7724,12 @@ "command": { "type": "string" }, + "commandWindows": { + "type": [ + "string", + "null" + ] + }, "statusMessage": { "type": [ "string", @@ -9878,6 +9951,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" }, @@ -9888,6 +9966,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": [ @@ -9904,9 +9987,11 @@ }, "required": [ "action", + "completedAtMs", "decisionSource", "review", "reviewId", + "startedAtMs", "threadId", "turnId" ], @@ -9927,6 +10012,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": [ @@ -9945,6 +10035,7 @@ "action", "review", "reviewId", + "startedAtMs", "threadId", "turnId" ], @@ -12185,10 +12276,20 @@ "null" ] }, + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "remotePluginId": { "type": "string" }, - "shareTargets": { + "sharePrincipals": { "items": { "$ref": "#/definitions/v2/PluginSharePrincipal" }, @@ -12249,14 +12350,10 @@ }, "plugin": { "$ref": "#/definitions/v2/PluginSummary" - }, - "shareUrl": { - "type": "string" } }, "required": [ - "plugin", - "shareUrl" + "plugin" ], "type": "object" }, @@ -12291,15 +12388,27 @@ }, "principalType": { "$ref": "#/definitions/v2/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/v2/PluginSharePrincipalRole" } }, "required": [ "name", "principalId", - "principalType" + "principalType", + "role" ], "type": "object" }, + "PluginSharePrincipalRole": { + "enum": [ + "reader", + "editor", + "owner" + ], + "type": "string" + }, "PluginSharePrincipalType": { "enum": [ "user", @@ -12370,17 +12479,38 @@ }, "principalType": { "$ref": "#/definitions/v2/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/v2/PluginShareTargetRole" } }, "required": [ "principalId", - "principalType" + "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" }, @@ -12392,6 +12522,7 @@ } }, "required": [ + "discoverability", "remotePluginId", "shareTargets" ], @@ -12401,6 +12532,9 @@ "PluginShareUpdateTargetsResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "discoverability": { + "$ref": "#/definitions/v2/PluginShareDiscoverability" + }, "principals": { "items": { "$ref": "#/definitions/v2/PluginSharePrincipal" @@ -12409,6 +12543,7 @@ } }, "required": [ + "discoverability", "principals" ], "title": "PluginShareUpdateTargetsResponse", @@ -12573,6 +12708,13 @@ "name": { "type": "string" }, + "remotePluginId": { + "description": "Backend remote plugin identifier when available.", + "type": [ + "string", + "null" + ] + }, "shareContext": { "anyOf": [ { @@ -13255,7 +13397,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": [ @@ -13263,11 +13405,15 @@ "null" ] }, + "installationId": { + "type": "string" + }, "status": { "$ref": "#/definitions/v2/RemoteControlConnectionStatus" } }, "required": [ + "installationId", "status" ], "title": "RemoteControlStatusChangedNotification", @@ -14730,24 +14876,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": { @@ -14761,17 +14889,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", @@ -18389,4 +18506,4 @@ }, "title": "CodexAppServerProtocol", "type": "object" -} +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 6153a54eb9..1926b22091 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 @@ -4113,6 +4113,12 @@ "command": { "type": "string" }, + "commandWindows": { + "type": [ + "string", + "null" + ] + }, "statusMessage": { "type": [ "string", @@ -6409,6 +6415,11 @@ "array", "null" ] + }, + "requestAttestation": { + "default": false, + "description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.", + "type": "boolean" } }, "type": "object" @@ -6489,6 +6500,11 @@ "action": { "$ref": "#/definitions/GuardianApprovalReviewAction" }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review completed.", + "format": "int64", + "type": "integer" + }, "decisionSource": { "$ref": "#/definitions/AutoReviewDecisionSource" }, @@ -6499,6 +6515,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": [ @@ -6515,9 +6536,11 @@ }, "required": [ "action", + "completedAtMs", "decisionSource", "review", "reviewId", + "startedAtMs", "threadId", "turnId" ], @@ -6538,6 +6561,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": [ @@ -6556,6 +6584,7 @@ "action", "review", "reviewId", + "startedAtMs", "threadId", "turnId" ], @@ -8796,10 +8825,20 @@ "null" ] }, + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "remotePluginId": { "type": "string" }, - "shareTargets": { + "sharePrincipals": { "items": { "$ref": "#/definitions/PluginSharePrincipal" }, @@ -8860,14 +8899,10 @@ }, "plugin": { "$ref": "#/definitions/PluginSummary" - }, - "shareUrl": { - "type": "string" } }, "required": [ - "plugin", - "shareUrl" + "plugin" ], "type": "object" }, @@ -8902,15 +8937,27 @@ }, "principalType": { "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginSharePrincipalRole" } }, "required": [ "name", "principalId", - "principalType" + "principalType", + "role" ], "type": "object" }, + "PluginSharePrincipalRole": { + "enum": [ + "reader", + "editor", + "owner" + ], + "type": "string" + }, "PluginSharePrincipalType": { "enum": [ "user", @@ -8981,17 +9028,38 @@ }, "principalType": { "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginShareTargetRole" } }, "required": [ "principalId", - "principalType" + "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" }, @@ -9003,6 +9071,7 @@ } }, "required": [ + "discoverability", "remotePluginId", "shareTargets" ], @@ -9012,6 +9081,9 @@ "PluginShareUpdateTargetsResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "discoverability": { + "$ref": "#/definitions/PluginShareDiscoverability" + }, "principals": { "items": { "$ref": "#/definitions/PluginSharePrincipal" @@ -9020,6 +9092,7 @@ } }, "required": [ + "discoverability", "principals" ], "title": "PluginShareUpdateTargetsResponse", @@ -9184,6 +9257,13 @@ "name": { "type": "string" }, + "remotePluginId": { + "description": "Backend remote plugin identifier when available.", + "type": [ + "string", + "null" + ] + }, "shareContext": { "anyOf": [ { @@ -9866,7 +9946,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": [ @@ -9874,11 +9954,15 @@ "null" ] }, + "installationId": { + "type": "string" + }, "status": { "$ref": "#/definitions/RemoteControlConnectionStatus" } }, "required": [ + "installationId", "status" ], "title": "RemoteControlStatusChangedNotification", @@ -12616,24 +12700,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": { @@ -12647,17 +12713,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", @@ -16274,4 +16329,4 @@ }, "title": "CodexAppServerProtocolV2", "type": "object" -} +} \ No newline at end of file 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/ConfigRequirementsReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json index 14a8d572d6..b229d8ef94 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigRequirementsReadResponse.json @@ -121,6 +121,12 @@ "command": { "type": "string" }, + "commandWindows": { + "type": [ + "string", + "null" + ] + }, "statusMessage": { "type": [ "string", 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/PluginListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json index b759d7a3fe..72b1f7c9aa 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -246,10 +246,20 @@ "null" ] }, + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "remotePluginId": { "type": "string" }, - "shareTargets": { + "sharePrincipals": { "items": { "$ref": "#/definitions/PluginSharePrincipal" }, @@ -270,6 +280,14 @@ ], "type": "object" }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, "PluginSharePrincipal": { "properties": { "name": { @@ -280,15 +298,27 @@ }, "principalType": { "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginSharePrincipalRole" } }, "required": [ "name", "principalId", - "principalType" + "principalType", + "role" ], "type": "object" }, + "PluginSharePrincipalRole": { + "enum": [ + "reader", + "editor", + "owner" + ], + "type": "string" + }, "PluginSharePrincipalType": { "enum": [ "user", @@ -422,6 +452,13 @@ "name": { "type": "string" }, + "remotePluginId": { + "description": "Backend remote plugin identifier when available.", + "type": [ + "string", + "null" + ] + }, "shareContext": { "anyOf": [ { 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 fe468884f1..945b4897cb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -300,10 +300,20 @@ "null" ] }, + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "remotePluginId": { "type": "string" }, - "shareTargets": { + "sharePrincipals": { "items": { "$ref": "#/definitions/PluginSharePrincipal" }, @@ -324,6 +334,14 @@ ], "type": "object" }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, "PluginSharePrincipal": { "properties": { "name": { @@ -334,15 +352,27 @@ }, "principalType": { "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginSharePrincipalRole" } }, "required": [ "name", "principalId", - "principalType" + "principalType", + "role" ], "type": "object" }, + "PluginSharePrincipalRole": { + "enum": [ + "reader", + "editor", + "owner" + ], + "type": "string" + }, "PluginSharePrincipalType": { "enum": [ "user", @@ -476,6 +506,13 @@ "name": { "type": "string" }, + "remotePluginId": { + "description": "Backend remote plugin identifier when available.", + "type": [ + "string", + "null" + ] + }, "shareContext": { "anyOf": [ { 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 96818dfead..ed2beacada 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json @@ -181,10 +181,20 @@ "null" ] }, + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "remotePluginId": { "type": "string" }, - "shareTargets": { + "sharePrincipals": { "items": { "$ref": "#/definitions/PluginSharePrincipal" }, @@ -205,6 +215,14 @@ ], "type": "object" }, + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, "PluginShareListItem": { "properties": { "localPluginPath": { @@ -219,14 +237,10 @@ }, "plugin": { "$ref": "#/definitions/PluginSummary" - }, - "shareUrl": { - "type": "string" } }, "required": [ - "plugin", - "shareUrl" + "plugin" ], "type": "object" }, @@ -240,15 +254,27 @@ }, "principalType": { "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginSharePrincipalRole" } }, "required": [ "name", "principalId", - "principalType" + "principalType", + "role" ], "type": "object" }, + "PluginSharePrincipalRole": { + "enum": [ + "reader", + "editor", + "owner" + ], + "type": "string" + }, "PluginSharePrincipalType": { "enum": [ "user", @@ -382,6 +408,13 @@ "name": { "type": "string" }, + "remotePluginId": { + "description": "Backend remote plugin identifier when available.", + "type": [ + "string", + "null" + ] + }, "shareContext": { "anyOf": [ { 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 c269223068..7ff4ac18ef 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareSaveParams.json @@ -28,13 +28,24 @@ }, "principalType": { "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginShareTargetRole" } }, "required": [ "principalId", - "principalType" + "principalType", + "role" ], "type": "object" + }, + "PluginShareTargetRole": { + "enum": [ + "reader", + "editor" + ], + "type": "string" } }, "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json index 080ec980a1..38a7d8d29f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsParams.json @@ -16,16 +16,37 @@ }, "principalType": { "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginShareTargetRole" } }, "required": [ "principalId", - "principalType" + "principalType", + "role" ], "type": "object" + }, + "PluginShareTargetRole": { + "enum": [ + "reader", + "editor" + ], + "type": "string" + }, + "PluginShareUpdateDiscoverability": { + "enum": [ + "UNLISTED", + "PRIVATE" + ], + "type": "string" } }, "properties": { + "discoverability": { + "$ref": "#/definitions/PluginShareUpdateDiscoverability" + }, "remotePluginId": { "type": "string" }, @@ -37,6 +58,7 @@ } }, "required": [ + "discoverability", "remotePluginId", "shareTargets" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json index 28fcb665ba..4923be498a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json @@ -1,6 +1,14 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "PluginShareDiscoverability": { + "enum": [ + "LISTED", + "UNLISTED", + "PRIVATE" + ], + "type": "string" + }, "PluginSharePrincipal": { "properties": { "name": { @@ -11,15 +19,27 @@ }, "principalType": { "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginSharePrincipalRole" } }, "required": [ "name", "principalId", - "principalType" + "principalType", + "role" ], "type": "object" }, + "PluginSharePrincipalRole": { + "enum": [ + "reader", + "editor", + "owner" + ], + "type": "string" + }, "PluginSharePrincipalType": { "enum": [ "user", @@ -30,6 +50,9 @@ } }, "properties": { + "discoverability": { + "$ref": "#/definitions/PluginShareDiscoverability" + }, "principals": { "items": { "$ref": "#/definitions/PluginSharePrincipal" @@ -38,6 +61,7 @@ } }, "required": [ + "discoverability", "principals" ], "title": "PluginShareUpdateTargetsResponse", 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/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/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/ServerRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts index 13d04b0be7..80e9ffc116 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts @@ -4,6 +4,7 @@ import type { ApplyPatchApprovalParams } from "./ApplyPatchApprovalParams"; import type { ExecCommandApprovalParams } from "./ExecCommandApprovalParams"; import type { RequestId } from "./RequestId"; +import type { AttestationGenerateParams } from "./v2/AttestationGenerateParams"; import type { ChatgptAuthTokensRefreshParams } from "./v2/ChatgptAuthTokensRefreshParams"; import type { CommandExecutionRequestApprovalParams } from "./v2/CommandExecutionRequestApprovalParams"; import type { DynamicToolCallParams } from "./v2/DynamicToolCallParams"; @@ -15,4 +16,4 @@ import type { ToolRequestUserInputParams } from "./v2/ToolRequestUserInputParams /** * Request initiated from the server and sent to the client. */ -export type ServerRequest = { "method": "item/commandExecution/requestApproval", id: RequestId, params: CommandExecutionRequestApprovalParams, } | { "method": "item/fileChange/requestApproval", id: RequestId, params: FileChangeRequestApprovalParams, } | { "method": "item/tool/requestUserInput", id: RequestId, params: ToolRequestUserInputParams, } | { "method": "mcpServer/elicitation/request", id: RequestId, params: McpServerElicitationRequestParams, } | { "method": "item/permissions/requestApproval", id: RequestId, params: PermissionsRequestApprovalParams, } | { "method": "item/tool/call", id: RequestId, params: DynamicToolCallParams, } | { "method": "account/chatgptAuthTokens/refresh", id: RequestId, params: ChatgptAuthTokensRefreshParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, }; +export type ServerRequest = { "method": "item/commandExecution/requestApproval", id: RequestId, params: CommandExecutionRequestApprovalParams, } | { "method": "item/fileChange/requestApproval", id: RequestId, params: FileChangeRequestApprovalParams, } | { "method": "item/tool/requestUserInput", id: RequestId, params: ToolRequestUserInputParams, } | { "method": "mcpServer/elicitation/request", id: RequestId, params: McpServerElicitationRequestParams, } | { "method": "item/permissions/requestApproval", id: RequestId, params: PermissionsRequestApprovalParams, } | { "method": "item/tool/call", id: RequestId, params: DynamicToolCallParams, } | { "method": "account/chatgptAuthTokens/refresh", id: RequestId, params: ChatgptAuthTokensRefreshParams, } | { "method": "attestation/generate", id: RequestId, params: AttestationGenerateParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsListExtraRootsForCwd.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AttestationGenerateParams.ts similarity index 62% rename from codex-rs/app-server-protocol/schema/typescript/v2/SkillsListExtraRootsForCwd.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/AttestationGenerateParams.ts index c18cd4ba1d..0e87e7d3e4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SkillsListExtraRootsForCwd.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AttestationGenerateParams.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 SkillsListExtraRootsForCwd = { cwd: string, extraUserRoots: Array, }; +export type AttestationGenerateParams = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AttestationGenerateResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AttestationGenerateResponse.ts new file mode 100644 index 0000000000..6821c898ec --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AttestationGenerateResponse.ts @@ -0,0 +1,9 @@ +// 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 AttestationGenerateResponse = { +/** + * Opaque client attestation token. + */ +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/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/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/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/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/PluginShareContext.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts index f1c5c958d7..86d610bf5a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareContext.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PluginShareDiscoverability } from "./PluginShareDiscoverability"; import type { PluginSharePrincipal } from "./PluginSharePrincipal"; -export type PluginShareContext = { remotePluginId: string, shareUrl: string | null, creatorAccountUserId: string | null, creatorName: string | null, shareTargets: Array | null, }; +export type PluginShareContext = { remotePluginId: string, 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/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 index 9e0ecc48e7..dd0dff2009 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipal.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipal.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PluginSharePrincipalRole } from "./PluginSharePrincipalRole"; import type { PluginSharePrincipalType } from "./PluginSharePrincipalType"; -export type PluginSharePrincipal = { principalType: PluginSharePrincipalType, principalId: string, name: string, }; +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/PluginShareTarget.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.ts index fd1969087f..66d22ef4a6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTarget.ts @@ -2,5 +2,6 @@ // 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, }; +export type PluginShareTarget = { principalType: PluginSharePrincipalType, principalId: string, role: PluginShareTargetRole, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTargetRole.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTargetRole.ts new file mode 100644 index 0000000000..95eee17be0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTargetRole.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 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 index 53ef2b3025..eecd4be82b 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsParams.ts @@ -2,5 +2,6 @@ // 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, shareTargets: Array, }; +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 index 3d6f6e9ee0..0ce722460f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginShareUpdateTargetsResponse.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PluginShareDiscoverability } from "./PluginShareDiscoverability"; import type { PluginSharePrincipal } from "./PluginSharePrincipal"; -export type PluginShareUpdateTargetsResponse = { principals: Array, }; +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 d855f3d31c..8fae7ae865 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts @@ -8,7 +8,11 @@ import type { PluginInterface } from "./PluginInterface"; import type { PluginShareContext } from "./PluginShareContext"; import type { PluginSource } from "./PluginSource"; -export type PluginSummary = { id: string, name: string, +export type PluginSummary = { id: string, +/** + * Backend remote plugin identifier when available. + */ +remotePluginId: string | null, name: string, /** * Remote sharing context associated with this plugin when available. */ 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/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 950dd9839a..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"; @@ -283,10 +285,13 @@ 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"; @@ -336,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"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index e79c99a9c9..ae00b08b73 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -581,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, @@ -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, @@ -1305,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). @@ -1649,7 +1669,6 @@ mod tests { params: v2::SkillsListParams { cwds: Vec::new(), force_reload: false, - per_cwd_extra_user_roots: None, }, }; assert_eq!( @@ -1784,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] @@ -1843,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 { @@ -1891,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(), @@ -1911,6 +1956,7 @@ mod tests { }, "capabilities": { "experimentalApi": true, + "requestAttestation": true, "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" @@ -1936,6 +1982,7 @@ mod tests { }, "capabilities": { "experimentalApi": true, + "requestAttestation": true, "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" @@ -1956,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(), @@ -2072,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 { @@ -2527,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"), }, @@ -2538,7 +2631,7 @@ mod tests { assert_eq!( json!({ "method": "fs/getMetadata", - "id": 9, + "id": 10, "params": { "path": absolute_path_string("tmp/example") } @@ -2799,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 { @@ -2947,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/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 1f45180c9e..1121d3a35b 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -2143,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, @@ -2160,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), @@ -2222,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, @@ -2525,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 { 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/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/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 8bc50bb1f2..35e487e45e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -413,6 +413,9 @@ pub enum ConfiguredHookHandler { #[ts(rename = "command")] Command { command: String, + #[serde(rename = "commandWindows")] + #[ts(rename = "commandWindows")] + command_windows: Option, #[serde(rename = "timeoutSec")] #[ts(rename = "timeoutSec")] timeout_sec: 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/item.rs b/codex-rs/app-server-protocol/src/protocol/v2/item.rs index 2c3a926c91..0e22c48590 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/item.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/item.rs @@ -1073,6 +1073,9 @@ pub struct ItemStartedNotification { 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. @@ -1099,6 +1102,12 @@ pub struct ItemGuardianApprovalReviewStartedNotification { 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. @@ -1248,6 +1257,9 @@ 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. @@ -1321,6 +1333,9 @@ 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, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs index 275e7ca45b..b5fa9fdc65 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -2,9 +2,11 @@ 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; @@ -26,9 +28,11 @@ 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::*; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index 8ce47e58cb..86614a6aeb 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -826,6 +826,9 @@ 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, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index 20d68e6e55..82568446b7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -15,7 +15,7 @@ use serde::Serialize; use std::path::PathBuf; use ts_rs::TS; -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct SkillsListParams { @@ -26,19 +26,6 @@ pub struct SkillsListParams { /// 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)] @@ -231,6 +218,7 @@ pub struct PluginShareSaveResponse { #[ts(export_to = "v2/")] pub struct PluginShareUpdateTargetsParams { pub remote_plugin_id: String, + pub discoverability: PluginShareUpdateDiscoverability, pub share_targets: Vec, } @@ -239,6 +227,7 @@ pub struct PluginShareUpdateTargetsParams { #[ts(export_to = "v2/")] pub struct PluginShareUpdateTargetsResponse { pub principals: Vec, + pub discoverability: PluginShareDiscoverability, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -270,7 +259,6 @@ pub struct PluginShareDeleteResponse {} #[ts(export_to = "v2/")] pub struct PluginShareListItem { pub plugin: PluginSummary, - pub share_url: String, pub local_plugin_path: Option, } @@ -288,6 +276,17 @@ pub enum PluginShareDiscoverability { 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 { @@ -308,6 +307,7 @@ pub enum PluginSharePrincipalType { pub struct PluginShareTarget { pub principal_type: PluginSharePrincipalType, pub principal_id: String, + pub role: PluginShareTargetRole, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] @@ -316,9 +316,29 @@ pub struct PluginShareTarget { 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")] @@ -518,6 +538,8 @@ pub enum PluginAvailability { #[ts(export_to = "v2/")] pub struct PluginSummary { pub id: String, + /// Backend remote plugin identifier when available. + pub remote_plugin_id: Option, pub name: String, /// Remote sharing context associated with this plugin when available. pub share_context: Option, @@ -539,10 +561,11 @@ pub struct PluginSummary { #[ts(export_to = "v2/")] pub struct PluginShareContext { pub remote_plugin_id: String, + pub discoverability: Option, pub share_url: Option, pub creator_account_user_id: Option, pub creator_name: Option, - pub share_targets: Option>, + pub share_principals: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] 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 index 7d6383f468..7dab24c3d4 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs @@ -3,12 +3,13 @@ use serde::Deserialize; use serde::Serialize; use ts_rs::TS; -/// Current remote-control connection status and environment id exposed to clients. +/// 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, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 4e923c5804..73b22cecb4 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -95,6 +95,59 @@ fn turn_defaults_legacy_missing_items_view_to_full() { 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!({ @@ -224,6 +277,7 @@ fn command_execution_request_approval_rejects_relative_additional_permission_pat "threadId": "thr_123", "turnId": "turn_123", "itemId": "call_123", + "startedAtMs": 1, "command": "cat file", "cwd": absolute_path_string("tmp"), "commandActions": null, @@ -264,6 +318,7 @@ fn permissions_request_approval_uses_request_permission_profile() { "threadId": "thr_123", "turnId": "turn_123", "itemId": "call_123", + "startedAtMs": 1, "cwd": absolute_path_string("repo"), "reason": "Select a workspace root", "permissions": { @@ -326,6 +381,7 @@ fn permissions_request_approval_rejects_macos_permissions() { "threadId": "thr_123", "turnId": "turn_123", "itemId": "call_123", + "startedAtMs": 1, "cwd": absolute_path_string("repo"), "reason": "Select a workspace root", "permissions": { @@ -2452,33 +2508,20 @@ fn skills_list_params_serialization_uses_force_reload() { serde_json::to_value(SkillsListParams { cwds: Vec::new(), force_reload: false, - per_cwd_extra_user_roots: None, }) .unwrap(), - json!({ - "perCwdExtraUserRoots": null, - }), + json!({}), ); 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"], - } - ], }), ); } @@ -2853,10 +2896,12 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() { PluginShareTarget { principal_type: PluginSharePrincipalType::User, principal_id: "user-1".to_string(), + role: PluginShareTargetRole::Reader, }, PluginShareTarget { - principal_type: PluginSharePrincipalType::Workspace, - principal_id: "workspace-1".to_string(), + principal_type: PluginSharePrincipalType::Group, + principal_id: "group-1".to_string(), + role: PluginShareTargetRole::Reader, }, ]), }) @@ -2869,10 +2914,12 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() { { "principalType": "user", "principalId": "user-1", + "role": "reader", }, { - "principalType": "workspace", - "principalId": "workspace-1", + "principalType": "group", + "principalId": "group-1", + "role": "reader", }, ], }), @@ -2893,17 +2940,21 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() { 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", }], }), ); @@ -2913,16 +2964,20 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() { 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", }), ); @@ -2948,7 +3003,10 @@ fn plugin_share_list_response_serializes_share_items() { serde_json::to_value(PluginShareListResponse { data: vec![PluginShareListItem { plugin: PluginSummary { - id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + id: "gmail@chatgpt-global".to_string(), + remote_plugin_id: Some( + "plugins~Plugin_00000000000000000000000000000000".to_string(), + ), name: "gmail".to_string(), share_context: None, source: PluginSource::Remote, @@ -2960,7 +3018,6 @@ fn plugin_share_list_response_serializes_share_items() { interface: None, keywords: Vec::new(), }, - share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), local_plugin_path: None, }], }) @@ -2968,7 +3025,8 @@ fn plugin_share_list_response_serializes_share_items() { json!({ "data": [{ "plugin": { - "id": "plugins~Plugin_00000000000000000000000000000000", + "id": "gmail@chatgpt-global", + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", "name": "gmail", "shareContext": null, "source": { "type": "remote" }, @@ -2980,7 +3038,6 @@ fn plugin_share_list_response_serializes_share_items() { "interface": null, "keywords": [], }, - "shareUrl": "https://chatgpt.example/plugins/share/share-key-1", "localPluginPath": null, }], }), diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 5f293ff8f6..458722b3a2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -6,9 +6,11 @@ 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; @@ -1005,6 +1007,9 @@ pub struct ThreadTurnsListParams { /// 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)] @@ -1022,6 +1027,36 @@ pub struct ThreadTurnsListResponse { 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/")] 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..523c6ada52 100644 --- a/codex-rs/app-server-transport/Cargo.toml +++ b/codex-rs/app-server-transport/Cargo.toml @@ -7,20 +7,13 @@ license.workspace = true [lib] name = "codex_app_server_transport" path = "src/lib.rs" +doctest = false [lints] workspace = true [dependencies] -anyhow = { workspace = true } -axum = { workspace = true, default-features = false, features = [ - "http1", - "json", - "tokio", - "ws", -] } base64 = { workspace = true } -clap = { workspace = true, features = ["derive"] } codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-core = { workspace = true } @@ -30,16 +23,10 @@ codex-state = { workspace = true } codex-uds = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-rustls-provider = { workspace = true } -constant_time_eq = { workspace = true } futures = { workspace = true } gethostname = { workspace = true } -hmac = { workspace = true } -jsonwebtoken = { workspace = true } -owo-colors = { workspace = true, features = ["supports-colors"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -sha2 = { workspace = true } -time = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", diff --git a/codex-rs/app-server-transport/src/lib.rs b/codex-rs/app-server-transport/src/lib.rs index 0a5c080acc..5d0adb642e 100644 --- a/codex-rs/app-server-transport/src/lib.rs +++ b/codex-rs/app-server-transport/src/lib.rs @@ -11,10 +11,9 @@ 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; pub use transport::start_control_socket_acceptor; pub use transport::start_remote_control; pub use transport::start_stdio_connection; -pub use transport::start_websocket_acceptor; diff --git a/codex-rs/app-server-transport/src/transport/auth.rs b/codex-rs/app-server-transport/src/transport/auth.rs deleted file mode 100644 index 9ec025f66f..0000000000 --- a/codex-rs/app-server-transport/src/transport/auth.rs +++ /dev/null @@ -1,751 +0,0 @@ -use anyhow::Context; -use axum::http::HeaderMap; -use axum::http::StatusCode; -use axum::http::header::AUTHORIZATION; -use clap::Args; -use clap::ValueEnum; -use codex_utils_absolute_path::AbsolutePathBuf; -use constant_time_eq::constant_time_eq_32; -use jsonwebtoken::Algorithm; -use jsonwebtoken::DecodingKey; -use jsonwebtoken::Validation; -use jsonwebtoken::decode; -use serde::Deserialize; -use sha2::Digest; -use sha2::Sha256; -use std::io; -use std::io::ErrorKind; -use std::net::SocketAddr; -use std::path::Path; -use std::path::PathBuf; -use time::OffsetDateTime; - -const DEFAULT_MAX_CLOCK_SKEW_SECONDS: u64 = 30; -const MIN_SIGNED_BEARER_SECRET_BYTES: usize = 32; -const INVALID_AUTHORIZATION_HEADER_MESSAGE: &str = "invalid authorization header"; - -#[derive(Debug, Clone, Default, PartialEq, Eq, Args)] -pub struct AppServerWebsocketAuthArgs { - /// Websocket auth mode for non-loopback listeners. - #[arg(long = "ws-auth", value_name = "MODE", value_enum)] - pub ws_auth: Option, - - /// Absolute path to the capability-token file. - #[arg(long = "ws-token-file", value_name = "PATH")] - pub ws_token_file: Option, - - /// Hex-encoded SHA-256 digest of the capability token. - #[arg(long = "ws-token-sha256", value_name = "HEX")] - pub ws_token_sha256: Option, - - /// Absolute path to the shared secret file for signed JWT bearer tokens. - #[arg(long = "ws-shared-secret-file", value_name = "PATH")] - pub ws_shared_secret_file: Option, - - /// Expected issuer for signed JWT bearer tokens. - #[arg(long = "ws-issuer", value_name = "ISSUER")] - pub ws_issuer: Option, - - /// Expected audience for signed JWT bearer tokens. - #[arg(long = "ws-audience", value_name = "AUDIENCE")] - pub ws_audience: Option, - - /// Maximum clock skew when validating signed JWT bearer tokens. - #[arg(long = "ws-max-clock-skew-seconds", value_name = "SECONDS")] - pub ws_max_clock_skew_seconds: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] -pub enum WebsocketAuthCliMode { - CapabilityToken, - SignedBearerToken, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct AppServerWebsocketAuthSettings { - pub config: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AppServerWebsocketAuthConfig { - CapabilityToken { - source: AppServerWebsocketCapabilityTokenSource, - }, - SignedBearerToken { - shared_secret_file: AbsolutePathBuf, - issuer: Option, - audience: Option, - max_clock_skew_seconds: u64, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AppServerWebsocketCapabilityTokenSource { - TokenFile { token_file: AbsolutePathBuf }, - TokenSha256 { token_sha256: [u8; 32] }, -} - -#[derive(Clone, Debug, Default)] -pub struct WebsocketAuthPolicy { - pub(crate) mode: Option, -} - -#[derive(Clone, Debug)] -pub(crate) enum WebsocketAuthMode { - CapabilityToken { - token_sha256: [u8; 32], - }, - SignedBearerToken { - shared_secret: Vec, - issuer: Option, - audience: Option, - max_clock_skew_seconds: i64, - }, -} - -#[derive(Debug)] -pub(crate) struct WebsocketAuthError { - status_code: StatusCode, - message: &'static str, -} - -#[derive(Deserialize)] -struct JwtClaims { - exp: i64, - nbf: Option, - iss: Option, - aud: Option, -} - -#[derive(Deserialize)] -#[serde(untagged)] -enum JwtAudienceClaim { - Single(String), - Multiple(Vec), -} - -impl WebsocketAuthError { - pub(crate) fn status_code(&self) -> StatusCode { - self.status_code - } - - pub(crate) fn message(&self) -> &'static str { - self.message - } -} - -impl AppServerWebsocketAuthArgs { - pub fn try_into_settings(self) -> anyhow::Result { - let normalize = |value: Option| { - value.and_then(|value| { - let trimmed = value.trim(); - (!trimmed.is_empty()).then(|| trimmed.to_string()) - }) - }; - - let config = match self.ws_auth { - Some(WebsocketAuthCliMode::CapabilityToken) => { - if self.ws_shared_secret_file.is_some() - || self.ws_issuer.is_some() - || self.ws_audience.is_some() - || self.ws_max_clock_skew_seconds.is_some() - { - anyhow::bail!( - "`--ws-shared-secret-file`, `--ws-issuer`, `--ws-audience`, and `--ws-max-clock-skew-seconds` require `--ws-auth signed-bearer-token`" - ); - } - let source = match (self.ws_token_file, self.ws_token_sha256) { - (Some(_), Some(_)) => { - anyhow::bail!( - "`--ws-token-file` and `--ws-token-sha256` are mutually exclusive" - ); - } - (Some(token_file), None) => { - AppServerWebsocketCapabilityTokenSource::TokenFile { - token_file: absolute_path_arg("--ws-token-file", token_file)?, - } - } - (None, Some(token_sha256)) => { - AppServerWebsocketCapabilityTokenSource::TokenSha256 { - token_sha256: sha256_digest_arg("--ws-token-sha256", &token_sha256)?, - } - } - (None, None) => { - anyhow::bail!( - "`--ws-token-file` or `--ws-token-sha256` is required when `--ws-auth capability-token` is set" - ); - } - }; - Some(AppServerWebsocketAuthConfig::CapabilityToken { source }) - } - Some(WebsocketAuthCliMode::SignedBearerToken) => { - if self.ws_token_file.is_some() || self.ws_token_sha256.is_some() { - anyhow::bail!( - "`--ws-token-file` and `--ws-token-sha256` require `--ws-auth capability-token`, not `signed-bearer-token`" - ); - } - let shared_secret_file = self.ws_shared_secret_file.context( - "`--ws-shared-secret-file` is required when `--ws-auth signed-bearer-token` is set", - )?; - Some(AppServerWebsocketAuthConfig::SignedBearerToken { - shared_secret_file: absolute_path_arg( - "--ws-shared-secret-file", - shared_secret_file, - )?, - issuer: normalize(self.ws_issuer), - audience: normalize(self.ws_audience), - max_clock_skew_seconds: self - .ws_max_clock_skew_seconds - .unwrap_or(DEFAULT_MAX_CLOCK_SKEW_SECONDS), - }) - } - None => { - if self.ws_token_file.is_some() - || self.ws_token_sha256.is_some() - || self.ws_shared_secret_file.is_some() - || self.ws_issuer.is_some() - || self.ws_audience.is_some() - || self.ws_max_clock_skew_seconds.is_some() - { - anyhow::bail!( - "websocket auth flags require `--ws-auth capability-token` or `--ws-auth signed-bearer-token`" - ); - } - None - } - }; - - Ok(AppServerWebsocketAuthSettings { config }) - } -} - -pub fn policy_from_settings( - settings: &AppServerWebsocketAuthSettings, -) -> io::Result { - let mode = match settings.config.as_ref() { - Some(AppServerWebsocketAuthConfig::CapabilityToken { source }) => match source { - AppServerWebsocketCapabilityTokenSource::TokenFile { token_file } => { - let token = read_trimmed_secret(token_file.as_ref())?; - Some(WebsocketAuthMode::CapabilityToken { - token_sha256: sha256_digest(token.as_bytes()), - }) - } - AppServerWebsocketCapabilityTokenSource::TokenSha256 { token_sha256 } => { - Some(WebsocketAuthMode::CapabilityToken { - token_sha256: *token_sha256, - }) - } - }, - Some(AppServerWebsocketAuthConfig::SignedBearerToken { - shared_secret_file, - issuer, - audience, - max_clock_skew_seconds, - }) => { - let shared_secret = read_trimmed_secret(shared_secret_file.as_ref())?.into_bytes(); - validate_signed_bearer_secret(shared_secret_file.as_ref(), &shared_secret)?; - let max_clock_skew_seconds = i64::try_from(*max_clock_skew_seconds).map_err(|_| { - io::Error::new( - ErrorKind::InvalidInput, - "websocket auth clock skew must fit in a signed 64-bit integer", - ) - })?; - Some(WebsocketAuthMode::SignedBearerToken { - shared_secret, - issuer: issuer.clone(), - audience: audience.clone(), - max_clock_skew_seconds, - }) - } - None => None, - }; - - Ok(WebsocketAuthPolicy { mode }) -} - -pub(crate) fn should_warn_about_unauthenticated_non_loopback_listener( - bind_address: SocketAddr, - policy: &WebsocketAuthPolicy, -) -> bool { - !bind_address.ip().is_loopback() && policy.mode.is_none() -} - -pub(crate) fn authorize_upgrade( - headers: &HeaderMap, - policy: &WebsocketAuthPolicy, -) -> Result<(), WebsocketAuthError> { - let Some(mode) = policy.mode.as_ref() else { - return Ok(()); - }; - - let token = bearer_token_from_headers(headers)?; - match mode { - WebsocketAuthMode::CapabilityToken { token_sha256 } => { - let actual_sha256 = sha256_digest(token.as_bytes()); - if constant_time_eq_32(token_sha256, &actual_sha256) { - Ok(()) - } else { - Err(unauthorized("invalid websocket bearer token")) - } - } - WebsocketAuthMode::SignedBearerToken { - shared_secret, - issuer, - audience, - max_clock_skew_seconds, - } => verify_signed_bearer_token( - token, - shared_secret, - issuer.as_deref(), - audience.as_deref(), - *max_clock_skew_seconds, - ), - } -} - -fn verify_signed_bearer_token( - token: &str, - shared_secret: &[u8], - issuer: Option<&str>, - audience: Option<&str>, - max_clock_skew_seconds: i64, -) -> Result<(), WebsocketAuthError> { - let claims = decode_jwt_claims(token, shared_secret)?; - validate_jwt_claims(&claims, issuer, audience, max_clock_skew_seconds) -} - -fn decode_jwt_claims(token: &str, shared_secret: &[u8]) -> Result { - let mut validation = Validation::new(Algorithm::HS256); - validation.required_spec_claims.clear(); - validation.validate_exp = false; - validation.validate_nbf = false; - validation.validate_aud = false; - - decode::(token, &DecodingKey::from_secret(shared_secret), &validation) - .map(|token_data| token_data.claims) - .map_err(|_| unauthorized("invalid websocket jwt")) -} - -fn validate_jwt_claims( - claims: &JwtClaims, - issuer: Option<&str>, - audience: Option<&str>, - max_clock_skew_seconds: i64, -) -> Result<(), WebsocketAuthError> { - let now = OffsetDateTime::now_utc().unix_timestamp(); - if now > claims.exp.saturating_add(max_clock_skew_seconds) { - return Err(unauthorized("expired websocket jwt")); - } - if let Some(nbf) = claims.nbf - && now < nbf.saturating_sub(max_clock_skew_seconds) - { - return Err(unauthorized("websocket jwt is not valid yet")); - } - if let Some(expected_issuer) = issuer - && claims.iss.as_deref() != Some(expected_issuer) - { - return Err(unauthorized("websocket jwt issuer mismatch")); - } - if let Some(expected_audience) = audience - && !audience_matches(claims.aud.as_ref(), expected_audience) - { - return Err(unauthorized("websocket jwt audience mismatch")); - } - - Ok(()) -} - -fn audience_matches(audience: Option<&JwtAudienceClaim>, expected_audience: &str) -> bool { - match audience { - Some(JwtAudienceClaim::Single(actual)) => actual == expected_audience, - Some(JwtAudienceClaim::Multiple(actual)) => { - actual.iter().any(|audience| audience == expected_audience) - } - None => false, - } -} - -fn bearer_token_from_headers(headers: &HeaderMap) -> Result<&str, WebsocketAuthError> { - let raw_header = headers - .get(AUTHORIZATION) - .ok_or_else(|| unauthorized("missing websocket bearer token"))?; - let header = raw_header - .to_str() - .map_err(|_| unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE))?; - let Some((scheme, token)) = header.split_once(' ') else { - return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE)); - }; - if !scheme.eq_ignore_ascii_case("Bearer") { - return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE)); - } - let token = token.trim(); - if token.is_empty() { - return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE)); - } - Ok(token) -} - -fn validate_signed_bearer_secret(path: &Path, shared_secret: &[u8]) -> io::Result<()> { - if shared_secret.len() < MIN_SIGNED_BEARER_SECRET_BYTES { - return Err(io::Error::new( - ErrorKind::InvalidInput, - format!( - "signed websocket bearer secret {} must be at least {MIN_SIGNED_BEARER_SECRET_BYTES} bytes", - path.display() - ), - )); - } - Ok(()) -} - -fn read_trimmed_secret(path: &std::path::Path) -> io::Result { - let raw = std::fs::read_to_string(path).map_err(|err| { - io::Error::new( - err.kind(), - format!( - "failed to read websocket auth secret {}: {err}", - path.display() - ), - ) - })?; - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err(io::Error::new( - ErrorKind::InvalidInput, - format!("websocket auth secret {} must not be empty", path.display()), - )); - } - Ok(trimmed.to_string()) -} - -fn absolute_path_arg(flag_name: &str, path: PathBuf) -> anyhow::Result { - AbsolutePathBuf::try_from(path).with_context(|| format!("{flag_name} must be an absolute path")) -} - -fn sha256_digest_arg(flag_name: &str, value: &str) -> anyhow::Result<[u8; 32]> { - let trimmed = value.trim(); - if trimmed.len() != 64 { - anyhow::bail!("{flag_name} must be a 64-character hex SHA-256 digest"); - } - - let mut digest = [0u8; 32]; - for (index, pair) in trimmed.as_bytes().chunks_exact(2).enumerate() { - let high = hex_nibble(flag_name, pair[0])?; - let low = hex_nibble(flag_name, pair[1])?; - digest[index] = (high << 4) | low; - } - Ok(digest) -} - -fn hex_nibble(flag_name: &str, byte: u8) -> anyhow::Result { - match byte { - b'0'..=b'9' => Ok(byte - b'0'), - b'a'..=b'f' => Ok(byte - b'a' + 10), - b'A'..=b'F' => Ok(byte - b'A' + 10), - _ => anyhow::bail!("{flag_name} must be a 64-character hex SHA-256 digest"), - } -} - -fn sha256_digest(input: &[u8]) -> [u8; 32] { - let mut digest = [0u8; 32]; - digest.copy_from_slice(&Sha256::digest(input)); - digest -} - -fn unauthorized(message: &'static str) -> WebsocketAuthError { - WebsocketAuthError { - status_code: StatusCode::UNAUTHORIZED, - message, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use axum::http::HeaderValue; - use base64::Engine; - use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use hmac::Hmac; - use hmac::Mac; - use serde_json::json; - - type HmacSha256 = Hmac; - - fn signed_token(shared_secret: &[u8], claims: serde_json::Value) -> String { - let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#); - let claims_segment = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap()); - let payload = format!("{header}.{claims_segment}"); - let mut mac = HmacSha256::new_from_slice(shared_secret).unwrap(); - mac.update(payload.as_bytes()); - let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); - format!("{payload}.{signature}") - } - - #[test] - fn warns_about_unauthenticated_non_loopback_listener() { - let policy = WebsocketAuthPolicy::default(); - assert!(should_warn_about_unauthenticated_non_loopback_listener( - "0.0.0.0:8765".parse().unwrap(), - &policy, - )); - assert!(!should_warn_about_unauthenticated_non_loopback_listener( - "127.0.0.1:8765".parse().unwrap(), - &policy, - )); - assert!(!should_warn_about_unauthenticated_non_loopback_listener( - "0.0.0.0:8765".parse().unwrap(), - &WebsocketAuthPolicy { - mode: Some(WebsocketAuthMode::CapabilityToken { - token_sha256: [0u8; 32], - }), - }, - )); - } - - #[test] - fn capability_token_args_require_token_file_or_hash() { - let err = AppServerWebsocketAuthArgs { - ws_auth: Some(WebsocketAuthCliMode::CapabilityToken), - ..Default::default() - } - .try_into_settings() - .expect_err("capability-token mode should require a token source"); - assert!( - err.to_string().contains("--ws-token-file") - && err.to_string().contains("--ws-token-sha256"), - "unexpected error: {err}" - ); - } - - #[test] - fn capability_token_args_accept_token_hash() { - let settings = AppServerWebsocketAuthArgs { - ws_auth: Some(WebsocketAuthCliMode::CapabilityToken), - ws_token_sha256: Some("ab".repeat(32)), - ..Default::default() - } - .try_into_settings() - .expect("capability-token hash args should parse"); - - assert_eq!( - settings, - AppServerWebsocketAuthSettings { - config: Some(AppServerWebsocketAuthConfig::CapabilityToken { - source: AppServerWebsocketCapabilityTokenSource::TokenSha256 { - token_sha256: [0xab; 32], - }, - }), - } - ); - } - - #[test] - fn capability_token_args_reject_multiple_token_sources() { - let err = AppServerWebsocketAuthArgs { - ws_auth: Some(WebsocketAuthCliMode::CapabilityToken), - ws_token_file: Some(PathBuf::from("/tmp/token")), - ws_token_sha256: Some("ab".repeat(32)), - ..Default::default() - } - .try_into_settings() - .expect_err("capability-token mode should reject multiple token sources"); - assert!( - err.to_string().contains("mutually exclusive"), - "unexpected error: {err}" - ); - } - - #[test] - fn capability_token_args_reject_malformed_token_hash() { - let err = AppServerWebsocketAuthArgs { - ws_auth: Some(WebsocketAuthCliMode::CapabilityToken), - ws_token_sha256: Some("not-a-sha256".to_string()), - ..Default::default() - } - .try_into_settings() - .expect_err("capability-token mode should reject malformed token hashes"); - assert!( - err.to_string().contains("64-character hex"), - "unexpected error: {err}" - ); - } - - #[test] - fn capability_token_hash_policy_authorizes_matching_bearer_token() { - let settings = AppServerWebsocketAuthSettings { - config: Some(AppServerWebsocketAuthConfig::CapabilityToken { - source: AppServerWebsocketCapabilityTokenSource::TokenSha256 { - token_sha256: sha256_digest(b"super-secret-token"), - }, - }), - }; - let policy = policy_from_settings(&settings).expect("hash policy should build"); - let mut headers = HeaderMap::new(); - headers.insert( - AUTHORIZATION, - HeaderValue::from_static("Bearer super-secret-token"), - ); - authorize_upgrade(&headers, &policy).expect("matching token should authorize"); - - headers.insert( - AUTHORIZATION, - HeaderValue::from_static("Bearer wrong-token"), - ); - let err = authorize_upgrade(&headers, &policy).expect_err("wrong token should fail"); - assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED); - } - - #[test] - fn signed_bearer_args_require_mode_when_mode_specific_flags_are_set() { - let err = AppServerWebsocketAuthArgs { - ws_shared_secret_file: Some(PathBuf::from("/tmp/secret")), - ..Default::default() - } - .try_into_settings() - .expect_err("mode-specific flags should require --ws-auth"); - assert!( - err.to_string().contains("websocket auth flags require"), - "unexpected error: {err}" - ); - } - - #[test] - fn signed_bearer_args_default_clock_skew_and_trim_optional_claims() { - let settings = AppServerWebsocketAuthArgs { - ws_auth: Some(WebsocketAuthCliMode::SignedBearerToken), - ws_shared_secret_file: Some(PathBuf::from("/tmp/secret")), - ws_issuer: Some(" issuer ".to_string()), - ws_audience: Some(" ".to_string()), - ..Default::default() - } - .try_into_settings() - .expect("signed bearer args should parse"); - - assert_eq!( - settings, - AppServerWebsocketAuthSettings { - config: Some(AppServerWebsocketAuthConfig::SignedBearerToken { - shared_secret_file: AbsolutePathBuf::from_absolute_path("/tmp/secret") - .expect("absolute path"), - issuer: Some("issuer".to_string()), - audience: None, - max_clock_skew_seconds: DEFAULT_MAX_CLOCK_SKEW_SECONDS, - }), - } - ); - } - - #[test] - fn signed_bearer_token_verification_rejects_tampering() { - let shared_secret = b"0123456789abcdef0123456789abcdef"; - let token = signed_token( - shared_secret, - json!({ - "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, - }), - ); - let tampered = token.replace(".eyJleHAi", ".eyJleHBi"); - let err = verify_signed_bearer_token( - &tampered, - shared_secret, - /*issuer*/ None, - /*audience*/ None, - /*max_clock_skew_seconds*/ 30, - ) - .expect_err("tampered jwt should fail"); - assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED); - } - - #[test] - fn signed_bearer_token_verification_accepts_valid_token() { - let shared_secret = b"0123456789abcdef0123456789abcdef"; - let token = signed_token( - shared_secret, - json!({ - "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, - "iss": "issuer", - "aud": "audience", - }), - ); - verify_signed_bearer_token( - &token, - shared_secret, - Some("issuer"), - Some("audience"), - /*max_clock_skew_seconds*/ 30, - ) - .expect("valid signed token should verify"); - } - - #[test] - fn signed_bearer_token_verification_accepts_multiple_audiences() { - let shared_secret = b"0123456789abcdef0123456789abcdef"; - let token = signed_token( - shared_secret, - json!({ - "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, - "aud": ["other-audience", "audience"], - }), - ); - verify_signed_bearer_token( - &token, - shared_secret, - /*issuer*/ None, - Some("audience"), - /*max_clock_skew_seconds*/ 30, - ) - .expect("jwt audience arrays should verify"); - } - - #[test] - fn signed_bearer_token_verification_rejects_alg_none_tokens() { - let claims_segment = URL_SAFE_NO_PAD.encode( - serde_json::to_vec(&json!({ - "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, - })) - .unwrap(), - ); - let header_segment = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#); - let token = format!("{header_segment}.{claims_segment}."); - let err = verify_signed_bearer_token( - &token, - b"0123456789abcdef0123456789abcdef", - /*issuer*/ None, - /*audience*/ None, - /*max_clock_skew_seconds*/ 30, - ) - .expect_err("alg=none jwt should be rejected"); - assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED); - } - - #[test] - fn signed_bearer_token_verification_rejects_missing_exp() { - let shared_secret = b"0123456789abcdef0123456789abcdef"; - let token = signed_token( - shared_secret, - json!({ - "iss": "issuer", - }), - ); - let err = verify_signed_bearer_token( - &token, - shared_secret, - /*issuer*/ None, - /*audience*/ None, - /*max_clock_skew_seconds*/ 30, - ) - .expect_err("jwt without exp should be rejected"); - assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED); - } - - #[test] - fn validate_signed_bearer_secret_rejects_short_secret() { - let err = validate_signed_bearer_secret(Path::new("/tmp/secret"), b"too-short") - .expect_err("short shared secret should be rejected"); - assert_eq!(err.kind(), ErrorKind::InvalidInput); - assert!( - err.to_string().contains("must be at least 32 bytes"), - "unexpected error: {err}" - ); - } -} diff --git a/codex-rs/app-server-transport/src/transport/mod.rs b/codex-rs/app-server-transport/src/transport/mod.rs index c63a79a0c1..dfd1d8fae7 100644 --- a/codex-rs/app-server-transport/src/transport/mod.rs +++ b/codex-rs/app-server-transport/src/transport/mod.rs @@ -1,5 +1,3 @@ -pub mod auth; - use crate::outgoing_message::ConnectionId; use crate::outgoing_message::OutgoingError; use crate::outgoing_message::OutgoingMessage; @@ -8,7 +6,6 @@ use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::JSONRPCMessage; use codex_core::config::find_codex_home; use codex_utils_absolute_path::AbsolutePathBuf; -use std::net::SocketAddr; use std::path::Path; use std::str::FromStr; use std::sync::atomic::AtomicU64; @@ -31,10 +28,10 @@ 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; -pub use websocket::start_websocket_acceptor; const OVERLOADED_ERROR_CODE: i64 = -32001; @@ -53,7 +50,6 @@ pub fn app_server_control_socket_path(codex_home: &Path) -> std::io::Result write!( f, - "unsupported --listen URL `{listen_url}`; expected `stdio://`, `unix://`, `unix://PATH`, `ws://IP:PORT`, or `off`" + "unsupported --listen URL `{listen_url}`; expected `stdio://`, `unix://`, `unix://PATH`, or `off`" ), AppServerTransportParseError::InvalidUnixSocketPath { listen_url, @@ -78,10 +73,6 @@ impl std::fmt::Display for AppServerTransportParseError { f, "invalid unix socket --listen URL `{listen_url}`; failed to resolve socket path: {message}" ), - AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url) => write!( - f, - "invalid websocket --listen URL `{listen_url}`; expected `ws://IP:PORT`" - ), } } } @@ -125,13 +116,6 @@ impl AppServerTransport { return Ok(Self::Off); } - if let Some(socket_addr) = listen_url.strip_prefix("ws://") { - let bind_address = socket_addr.parse::().map_err(|_| { - AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url.to_string()) - })?; - return Ok(Self::WebSocket { bind_address }); - } - Err(AppServerTransportParseError::UnsupportedListenUrl( listen_url.to_string(), )) @@ -167,7 +151,7 @@ pub enum TransportEvent { pub enum ConnectionOrigin { Stdio, InProcess, - WebSocket, + UnixSocket, RemoteControl, } 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..a44c8522d9 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 @@ -1,7 +1,6 @@ use super::protocol::EnrollRemoteServerRequest; use super::protocol::EnrollRemoteServerResponse; use super::protocol::RemoteControlTarget; -use axum::http::HeaderMap; use codex_api::SharedAuthProvider; use codex_login::default_client::build_reqwest_client; use codex_state::RemoteControlEnrollmentRecord; @@ -9,6 +8,7 @@ use codex_state::StateRuntime; use gethostname::gethostname; use std::io; use std::io::ErrorKind; +use tokio_tungstenite::tungstenite::http::HeaderMap; use tracing::info; use tracing::warn; @@ -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..c3111fbc79 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 @@ -19,7 +19,6 @@ use super::segment::ClientSegmentObservation; use super::segment::ClientSegmentReassembler; use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES; use super::segment::split_server_envelope_for_transport; -use axum::http::HeaderValue; use base64::Engine; use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlStatusChangedNotification; @@ -48,6 +47,7 @@ use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite; use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_util::sync::CancellationToken; use tracing::error; use tracing::info; @@ -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/unix_socket_tests.rs b/codex-rs/app-server-transport/src/transport/unix_socket_tests.rs index 0b7dec0a23..4c77d84881 100644 --- a/codex-rs/app-server-transport/src/transport/unix_socket_tests.rs +++ b/codex-rs/app-server-transport/src/transport/unix_socket_tests.rs @@ -52,6 +52,14 @@ fn listen_unix_socket_accepts_relative_custom_path() { ); } +#[test] +fn listen_websocket_url_is_not_supported() { + assert!(matches!( + AppServerTransport::from_listen_url("ws://127.0.0.1:0"), + Err(super::AppServerTransportParseError::UnsupportedListenUrl(_)) + )); +} + #[tokio::test] async fn control_socket_acceptor_upgrades_and_forwards_websocket_text_messages_and_pings() { let temp_dir = tempfile::TempDir::new().expect("temp dir"); diff --git a/codex-rs/app-server-transport/src/transport/websocket.rs b/codex-rs/app-server-transport/src/transport/websocket.rs index 627197c29b..086f76d7a8 100644 --- a/codex-rs/app-server-transport/src/transport/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/websocket.rs @@ -1,46 +1,17 @@ use super::CHANNEL_CAPACITY; 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::forward_incoming_message; use super::next_connection_id; use super::serialize_outgoing_message; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::QueuedOutgoingMessage; -use axum::Router; -use axum::body::Body; -use axum::body::Bytes; -use axum::extract::ConnectInfo; -use axum::extract::State; -use axum::extract::ws::Message as AxumWebSocketMessage; -use axum::extract::ws::WebSocketUpgrade; -use axum::http::HeaderMap; -use axum::http::Request; -use axum::http::StatusCode; -use axum::http::header::ORIGIN; -use axum::middleware; -use axum::middleware::Next; -use axum::response::IntoResponse; -use axum::response::Response; -use axum::routing::any; -use axum::routing::get; use futures::SinkExt; use futures::StreamExt; -use owo_colors::OwoColorize; -use owo_colors::Stream; -use owo_colors::Style; -use std::io::Result as IoResult; -use std::net::SocketAddr; -use std::sync::Arc; -use tokio::net::TcpListener; use tokio::sync::mpsc; -use tokio::task::JoinHandle; +use tokio_tungstenite::tungstenite::Bytes; use tokio_tungstenite::tungstenite::Message as TungsteniteWebSocketMessage; use tokio_util::sync::CancellationToken; -use tracing::error; -use tracing::info; use tracing::warn; /// WebSocket clients can briefly lag behind normal turn output bursts while the @@ -48,127 +19,6 @@ use tracing::warn; const WEBSOCKET_OUTBOUND_CHANNEL_CAPACITY: usize = 32 * 1024; const _: () = assert!(WEBSOCKET_OUTBOUND_CHANNEL_CAPACITY > CHANNEL_CAPACITY); -fn colorize(text: &str, style: Style) -> String { - text.if_supports_color(Stream::Stderr, |value| value.style(style)) - .to_string() -} - -#[allow(clippy::print_stderr)] -fn print_websocket_startup_banner(addr: SocketAddr) { - let title = colorize("codex app-server (WebSockets)", Style::new().bold().cyan()); - let listening_label = colorize("listening on:", Style::new().dimmed()); - let listen_url = colorize(&format!("ws://{addr}"), Style::new().green()); - let ready_label = colorize("readyz:", Style::new().dimmed()); - let ready_url = colorize(&format!("http://{addr}/readyz"), Style::new().green()); - let health_label = colorize("healthz:", Style::new().dimmed()); - let health_url = colorize(&format!("http://{addr}/healthz"), Style::new().green()); - let note_label = colorize("note:", Style::new().dimmed()); - eprintln!("{title}"); - eprintln!(" {listening_label} {listen_url}"); - eprintln!(" {ready_label} {ready_url}"); - eprintln!(" {health_label} {health_url}"); - if addr.ip().is_loopback() { - eprintln!( - " {note_label} binds localhost only (use SSH port-forwarding for remote access)" - ); - } else { - eprintln!( - " {note_label} websocket auth is opt-in in this build; configure `--ws-auth ...` before real remote use" - ); - } -} - -#[derive(Clone)] -struct WebSocketListenerState { - transport_event_tx: mpsc::Sender, - auth_policy: Arc, -} - -async fn health_check_handler() -> StatusCode { - StatusCode::OK -} - -async fn reject_requests_with_origin_header( - request: Request, - next: Next, -) -> Result { - if request.headers().contains_key(ORIGIN) { - warn!( - method = %request.method(), - uri = %request.uri(), - "rejecting websocket listener request with Origin header" - ); - Err(StatusCode::FORBIDDEN) - } else { - Ok(next.run(request).await) - } -} - -async fn websocket_upgrade_handler( - websocket: WebSocketUpgrade, - ConnectInfo(peer_addr): ConnectInfo, - State(state): State, - headers: HeaderMap, -) -> impl IntoResponse { - if let Err(err) = authorize_upgrade(&headers, state.auth_policy.as_ref()) { - warn!( - %peer_addr, - message = err.message(), - "rejecting websocket client during upgrade" - ); - return (err.status_code(), err.message()).into_response(); - } - info!(%peer_addr, "websocket client connected"); - websocket - .on_upgrade(move |stream| async move { - let (websocket_writer, websocket_reader) = stream.split(); - run_websocket_connection(websocket_writer, websocket_reader, state.transport_event_tx) - .await; - }) - .into_response() -} - -pub async fn start_websocket_acceptor( - bind_address: SocketAddr, - transport_event_tx: mpsc::Sender, - shutdown_token: CancellationToken, - auth_policy: WebsocketAuthPolicy, -) -> IoResult> { - if should_warn_about_unauthenticated_non_loopback_listener(bind_address, &auth_policy) { - warn!( - %bind_address, - "starting non-loopback websocket listener without auth; websocket auth is opt-in for now and will become the default in a future release" - ); - } - let listener = TcpListener::bind(bind_address).await?; - let local_addr = listener.local_addr()?; - print_websocket_startup_banner(local_addr); - info!("app-server websocket listening on ws://{local_addr}"); - - let router = Router::new() - .route("/readyz", get(health_check_handler)) - .route("/healthz", get(health_check_handler)) - .fallback(any(websocket_upgrade_handler)) - .layer(middleware::from_fn(reject_requests_with_origin_header)) - .with_state(WebSocketListenerState { - transport_event_tx, - auth_policy: Arc::new(auth_policy), - }); - let server = axum::serve( - listener, - router.into_make_service_with_connect_info::(), - ) - .with_graceful_shutdown(async move { - shutdown_token.cancelled().await; - }); - Ok(tokio::spawn(async move { - if let Err(err) = server.await { - error!("websocket acceptor failed: {err}"); - } - info!("websocket acceptor shutting down"); - })) -} - pub(crate) async fn run_websocket_connection( websocket_writer: impl futures::sink::Sink + Send + 'static, websocket_reader: impl futures::stream::Stream> + Send + 'static, @@ -186,7 +36,7 @@ pub(crate) async fn run_websocket_connection( if transport_event_tx .send(TransportEvent::ConnectionOpened { connection_id, - origin: ConnectionOrigin::WebSocket, + origin: ConnectionOrigin::UnixSocket, writer: writer_tx, disconnect_sender: Some(disconnect_token.clone()), }) @@ -245,26 +95,6 @@ pub(crate) trait AppServerWebSocketMessage: Sized { fn into_incoming(self) -> Option; } -impl AppServerWebSocketMessage for AxumWebSocketMessage { - fn text(text: String) -> Self { - Self::Text(text.into()) - } - - fn pong(payload: Bytes) -> Self { - Self::Pong(payload) - } - - fn into_incoming(self) -> Option { - Some(match self { - Self::Text(text) => IncomingWebSocketMessage::Text(text.to_string()), - Self::Binary(_) => IncomingWebSocketMessage::Binary, - Self::Ping(payload) => IncomingWebSocketMessage::Ping(payload), - Self::Pong(_) => IncomingWebSocketMessage::Pong, - Self::Close(_) => IncomingWebSocketMessage::Close, - }) - } -} - impl AppServerWebSocketMessage for TungsteniteWebSocketMessage { fn text(text: String) -> Self { Self::Text(text.into()) diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 55481b6d5d..fcf731aa9b 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 @@ -27,7 +28,6 @@ axum = { workspace = true, default-features = false, features = [ "http1", "json", "tokio", - "ws", ] } codex-analytics = { workspace = true } codex-arg0 = { workspace = true } @@ -36,10 +36,14 @@ codex-config = { workspace = true } codex-core = { workspace = true } codex-core-plugins = { 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 } @@ -96,22 +100,20 @@ axum = { workspace = true, default-features = false, features = [ "tokio", ] } base64 = { workspace = true } +codex-uds = { 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 } opentelemetry_sdk = { workspace = true } pretty_assertions = { workspace = true } -reqwest = { workspace = true, features = ["rustls-tls"] } rmcp = { workspace = true, default-features = false, features = [ "elicitation", "server", "transport-streamable-http-server", ] } serial_test = { workspace = true } -sha2 = { workspace = true } shlex = { workspace = true } tar = { workspace = true } tokio-tungstenite = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index babfac99ba..c167627257 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -24,35 +24,14 @@ Similar to [MCP](https://modelcontextprotocol.io/), `codex app-server` supports Supported transports: - stdio (`--listen stdio://`, default): newline-delimited JSON (JSONL) -- websocket (`--listen ws://IP:PORT`): one JSON-RPC message per websocket text frame (**experimental / unsupported**) - unix socket (`--listen unix://` or `--listen unix://PATH`): websocket connections over `$CODEX_HOME/app-server-control/app-server-control.sock` or a custom socket path, using the standard HTTP Upgrade handshake - off (`--listen off`): do not expose a local transport -When running with `--listen ws://IP:PORT`, the same listener also serves basic HTTP health probes: - -- `GET /readyz` returns `200 OK` once the listener is accepting new connections. -- `GET /healthz` returns `200 OK` when no `Origin` header is present. -- Any request carrying an `Origin` header is rejected with `403 Forbidden`. - -Websocket transport is currently experimental and unsupported. Do not rely on it for production workloads. - The unix socket transport is intended for local app-server control-plane clients. `codex app-server proxy` opens exactly one raw stream connection to `$CODEX_HOME/app-server-control/app-server-control.sock` by default, or to `--sock PATH` when provided, and proxies bytes between that socket and stdin/stdout. The 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 +128,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. @@ -201,6 +181,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. @@ -424,13 +405,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`). Current `thread/turns/list` responses return `full` turns. +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": [ ... ], @@ -439,6 +421,19 @@ Every returned `Turn` includes `itemsView`, which tells clients whether the `ite } } ``` +`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. @@ -486,7 +481,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": { @@ -1322,6 +1317,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`. @@ -1470,21 +1469,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": [{ diff --git a/codex-rs/app-server/src/app_server_tracing.rs b/codex-rs/app-server/src/app_server_tracing.rs index 6e8133740f..21acf1c86f 100644 --- a/codex-rs/app-server/src/app_server_tracing.rs +++ b/codex-rs/app-server/src/app_server_tracing.rs @@ -1,7 +1,7 @@ //! Tracing helpers shared by socket and in-process app-server entry points. //! //! The in-process path intentionally reuses the same span shape as JSON-RPC -//! transports so request telemetry stays comparable across stdio, websocket, +//! transports so request telemetry stays comparable across stdio, unix socket, //! and embedded callers. [`typed_request_span`] is the in-process counterpart //! of [`request_span`] and stamps `rpc.transport` as `"in-process"` while //! deriving client identity from the typed [`ClientRequest`] rather than @@ -86,7 +86,6 @@ fn transport_name(transport: &AppServerTransport) -> &'static str { match transport { AppServerTransport::Stdio => "stdio", AppServerTransport::UnixSocket { .. } => "unix_socket", - AppServerTransport::WebSocket { .. } => "websocket", AppServerTransport::Off => "off", } } 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 64215566ee..ddd7bf1a38 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -49,7 +49,6 @@ 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::ThreadRealtimeClosedNotification; @@ -194,13 +193,6 @@ pub(crate) async fn apply_bespoke_event_handling( ) .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 => { @@ -511,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(), }; @@ -542,6 +535,7 @@ pub(crate) async fn apply_bespoke_event_handling( call_id, approval_id, turn_id, + started_at_ms, command, cwd, reason, @@ -615,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, @@ -764,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(), @@ -776,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, }; @@ -1749,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, @@ -1762,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 { @@ -1779,6 +1778,7 @@ struct PendingRequestPermissionsResponse { requested_permissions: CoreRequestPermissionProfile, request_cwd: AbsolutePathBuf, pending_request_id: RequestId, + outgoing: ThreadScopedOutgoingMessageSender, receiver: oneshot::Receiver, request_permissions_guard: ThreadWatchActiveGuard, } @@ -2186,7 +2186,7 @@ mod tests { assert_eq!(response.thread.id, thread_id.to_string()); assert_eq!(response.thread.path, None); - assert_eq!(response.thread.preview, "before rollback"); + 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); @@ -2249,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, @@ -2313,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, @@ -2326,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!( @@ -2356,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), @@ -2371,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); @@ -2406,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, 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/lib.rs b/codex-rs/app-server/src/lib.rs index 08aab99f65..f655f65b91 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -8,7 +8,6 @@ use codex_config::RemoteThreadConfigLoader; use codex_config::ThreadConfigLoader; use codex_core::config::Config; use codex_core::resolve_installation_id; -use codex_exec_server::EnvironmentManagerArgs; use codex_features::Feature; use codex_login::AuthManager; use codex_utils_cli::CliConfigOverrides; @@ -31,13 +30,12 @@ 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; use crate::transport::start_control_socket_acceptor; use crate::transport::start_remote_control; use crate::transport::start_stdio_connection; -use crate::transport::start_websocket_acceptor; use codex_analytics::AppServerRpcTransport; use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ConfigWarningNotification; @@ -74,6 +72,7 @@ use tracing_subscriber::util::SubscriberInitExt; mod analytics_utils; mod app_server_tracing; +mod attestation; mod bespoke_event_handling; mod command_exec; mod config; @@ -82,6 +81,7 @@ mod config_manager_service; mod connection_rpc_gate; mod dynamic_tools; mod error_code; +mod extensions; mod filters; mod fs_watch; mod fuzzy_file_search; @@ -93,6 +93,7 @@ mod outgoing_message; mod request_processors; mod request_serialization; mod server_request_error; +mod skills_watcher; mod thread_state; mod thread_status; mod transport; @@ -101,11 +102,9 @@ pub use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE; pub use crate::error_code::INVALID_PARAMS_ERROR_CODE; pub use crate::transport::AppServerTransport; pub use crate::transport::app_server_control_socket_path; -pub use crate::transport::auth::AppServerWebsocketAuthArgs; -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 { @@ -159,22 +158,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) } } @@ -187,9 +197,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; } @@ -362,7 +379,6 @@ pub async fn run_main( default_analytics_enabled, AppServerTransport::Stdio, SessionSource::VSCode, - AppServerWebsocketAuthSettings::default(), ) .await } @@ -393,7 +409,6 @@ pub async fn run_main_with_transport( default_analytics_enabled: bool, transport: AppServerTransport, session_source: SessionSource, - auth: AppServerWebsocketAuthSettings, ) -> IoResult<()> { run_main_with_transport_options( arg0_paths, @@ -402,7 +417,6 @@ pub async fn run_main_with_transport( default_analytics_enabled, transport, session_source, - auth, AppServerRuntimeOptions::default(), ) .await @@ -416,18 +430,8 @@ pub async fn run_main_with_transport_options( default_analytics_enabled: bool, transport: AppServerTransport, session_source: SessionSource, - 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); @@ -443,6 +447,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(), @@ -489,6 +504,20 @@ pub async fn run_main_with_transport_options( } }; + 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(); @@ -568,19 +597,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`. @@ -653,16 +669,6 @@ pub async fn run_main_with_transport_options( .await?; transport_accept_handles.push(accept_handle); } - AppServerTransport::WebSocket { bind_address } => { - let accept_handle = start_websocket_acceptor( - *bind_address, - transport_event_tx.clone(), - transport_shutdown_token.clone(), - policy_from_settings(&auth)?, - ) - .await?; - transport_accept_handles.push(accept_handle); - } AppServerTransport::Off => {} } @@ -686,7 +692,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(), @@ -731,7 +740,7 @@ pub async fn run_main_with_transport_options( } OutboundControlEvent::DisconnectAll => { info!( - "disconnecting {} outbound websocket connection(s) for graceful restart", + "disconnecting {} outbound connection(s) for graceful restart", outbound_connections.len() ); for connection_state in outbound_connections.values() { @@ -808,11 +817,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() { @@ -933,7 +946,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); @@ -977,6 +997,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, }, )) @@ -1047,9 +1068,9 @@ pub async fn run_main_with_transport_options( fn analytics_rpc_transport(transport: &AppServerTransport) -> AppServerRpcTransport { match transport { AppServerTransport::Stdio => AppServerRpcTransport::Stdio, - AppServerTransport::UnixSocket { .. } - | AppServerTransport::WebSocket { .. } - | AppServerTransport::Off => AppServerRpcTransport::Websocket, + AppServerTransport::UnixSocket { .. } | AppServerTransport::Off => { + AppServerRpcTransport::Websocket + } } } diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 1cb4bd9a8e..6e10bf7f80 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -1,7 +1,6 @@ use clap::Parser; use codex_app_server::AppServerRuntimeOptions; use codex_app_server::AppServerTransport; -use codex_app_server::AppServerWebsocketAuthArgs; use codex_app_server::PluginStartupTasks; use codex_app_server::run_main_with_transport_options; use codex_arg0::Arg0DispatchPaths; @@ -19,7 +18,7 @@ const DISABLE_MANAGED_CONFIG_ENV_VAR: &str = "CODEX_APP_SERVER_DISABLE_MANAGED_C #[derive(Debug, Parser)] struct AppServerArgs { /// Transport endpoint URL. Supported values: `stdio://` (default), - /// `unix://`, `unix://PATH`, `ws://IP:PORT`, `off`. + /// `unix://`, `unix://PATH`, `off`. #[arg( long = "listen", value_name = "URL", @@ -36,9 +35,6 @@ struct AppServerArgs { )] session_source: SessionSource, - #[command(flatten)] - auth: AppServerWebsocketAuthArgs, - /// Hidden debug-only test hook used by integration tests that spawn the /// production app-server binary. #[cfg(debug_assertions)] @@ -58,7 +54,6 @@ fn main() -> anyhow::Result<()> { }; let transport = args.listen; let session_source = args.session_source; - let auth = args.auth.try_into_settings()?; let mut runtime_options = AppServerRuntimeOptions::default(); #[cfg(debug_assertions)] if args.disable_plugin_startup_tasks_for_tests { @@ -72,7 +67,6 @@ fn main() -> anyhow::Result<()> { /*default_analytics_enabled*/ false, transport, session_source, - auth, runtime_options, ) .await?; diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index 8e1ccd3c0a..31a6f4afd8 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -99,6 +99,8 @@ async fn queue_refresh( #[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; @@ -178,16 +180,20 @@ mod tests { .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(ThreadManager::new( - &good_config, - auth_manager, - SessionSource::Exec, - Arc::new(EnvironmentManager::default_for_tests()), - /*analytics_events_client*/ None, - thread_store, - Some(state_db.clone()), - "11111111-1111-4111-8111-111111111111".to_string(), - )); + 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?; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 2ca0a87d84..867cd64c99 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -4,9 +4,12 @@ use std::sync::Arc; use std::sync::OnceLock; use std::sync::atomic::AtomicBool; +use crate::attestation::app_server_attestation_provider; use crate::config_manager::ConfigManager; use crate::connection_rpc_gate::ConnectionRpcGate; use crate::error_code::invalid_request; +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; @@ -17,6 +20,7 @@ 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; @@ -34,6 +38,8 @@ 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::RemoteControlHandle; @@ -60,7 +66,6 @@ use codex_arg0::Arg0DispatchPaths; 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_feedback::CodexFeedback; use codex_login::AuthManager; @@ -82,6 +87,7 @@ use tokio::time::timeout; use tracing::Instrument; const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); + #[derive(Clone)] struct ExternalAuthRefreshBridge { outgoing: Arc, @@ -158,6 +164,7 @@ pub(crate) struct MessageProcessor { 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, @@ -186,6 +193,7 @@ pub(crate) struct InitializedConnectionSessionState { 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 { @@ -231,6 +239,12 @@ impl ConnectionSessionState { .map(|session| session.client_version.as_str()) } + 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(|_| ()) } @@ -280,26 +294,34 @@ impl MessageProcessor { 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(), state_db.clone()); - 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), - state_db.clone(), - installation_id, - )); + 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 pending_thread_unloads = Arc::new(Mutex::new(HashSet::new())); - let thread_state_manager = ThreadStateManager::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)); @@ -389,6 +411,7 @@ impl MessageProcessor { Arc::clone(&thread_list_state_permit), thread_goal_processor.clone(), state_db, + Arc::clone(&skills_watcher), ); let turn_processor = TurnRequestProcessor::new( auth_manager.clone(), @@ -402,6 +425,7 @@ impl MessageProcessor { 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. @@ -432,6 +456,8 @@ impl MessageProcessor { 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() @@ -453,6 +479,7 @@ impl MessageProcessor { command_exec_processor, process_exec_processor, config_processor, + environment_processor, external_agent_config_processor, feedback_processor, fs_processor, @@ -620,9 +647,18 @@ impl MessageProcessor { .await; } - pub(crate) async fn connection_initialized(&self, connection_id: ConnectionId) { + pub(crate) async fn connection_initialized( + &self, + connection_id: ConnectionId, + request_attestation: bool, + ) { self.thread_processor - .connection_initialized(connection_id) + .connection_initialized( + connection_id, + ConnectionCapabilities { + request_attestation, + }, + ) .await; } @@ -718,7 +754,12 @@ impl MessageProcessor { .await?; if connection_initialized { self.thread_processor - .connection_initialized(connection_id) + .connection_initialized( + connection_id, + ConnectionCapabilities { + request_attestation: session.request_attestation(), + }, + ) .await; } return Ok(()); @@ -850,6 +891,9 @@ impl MessageProcessor { .config_requirements_read() .await .map(|response| Some(response.into())), + ClientRequest::EnvironmentAdd { params, .. } => { + self.environment_processor.environment_add(params).await + } ClientRequest::FsReadFile { params, .. } => self .fs_processor .read_file(params) @@ -1008,6 +1052,9 @@ impl MessageProcessor { 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) diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index a7420f8c78..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; @@ -139,6 +143,20 @@ 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 @@ -265,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, @@ -357,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:?}"); @@ -376,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:?}"); } @@ -387,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) { @@ -399,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:?}"); } } } @@ -455,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:?}",); } } } @@ -648,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; @@ -903,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, @@ -1195,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, }, diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 795b5d4c32..f24dcaa34f 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -11,6 +11,7 @@ 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; @@ -49,6 +50,8 @@ 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; @@ -124,6 +127,7 @@ 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; @@ -215,6 +219,7 @@ 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; @@ -272,7 +277,6 @@ 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_thread_path_by_id_str; use codex_core::path_utils; #[cfg(test)] use codex_core::read_head_for_summary; @@ -432,6 +436,7 @@ 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; @@ -452,6 +457,7 @@ 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; @@ -471,6 +477,7 @@ 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; @@ -481,12 +488,14 @@ 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; diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index b26748d551..89082492c1 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -382,44 +382,12 @@ impl CatalogRequestProcessor { &self, params: SkillsListParams, ) -> Result { - let SkillsListParams { - cwds, - force_reload, - per_cwd_extra_user_roots, - } = params; + let SkillsListParams { cwds, force_reload } = 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; @@ -436,7 +404,6 @@ impl CatalogRequestProcessor { let mut data = futures::stream::iter(cwds.into_iter().enumerate()) .map(|(index, cwd)| { let config = &config; - let extra_roots_by_cwd = &extra_roots_by_cwd; let fs = fs.clone(); let plugins_manager = &plugins_manager; let skills_manager = &skills_manager; @@ -458,9 +425,6 @@ impl CatalogRequestProcessor { ); } }; - 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 @@ -479,12 +443,7 @@ impl CatalogRequestProcessor { config.bundled_skills_enabled(), ); let outcome = skills_manager - .skills_for_cwd_with_extra_user_roots( - &skills_input, - force_reload, - extra_roots, - fs, - ) + .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); diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index cda2bbe61d..5365030d6b 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -52,6 +52,7 @@ use std::path::PathBuf; const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ "apps", "memories", + "mentions_v2", "plugins", "remote_control", "tool_search", @@ -514,11 +515,13 @@ fn map_hook_handler_to_api(handler: CoreHookHandlerConfig) -> ConfiguredHookHand match handler { CoreHookHandlerConfig::Command { command, + command_windows, timeout_sec, r#async, status_message, } => ConfiguredHookHandler::Command { command, + command_windows, timeout_sec, r#async, status_message, 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/request_processors/initialize_processor.rs b/codex-rs/app-server/src/request_processors/initialize_processor.rs index a206b2faa0..c33af189cf 100644 --- a/codex-rs/app-server/src/request_processors/initialize_processor.rs +++ b/codex-rs/app-server/src/request_processors/initialize_processor.rs @@ -13,6 +13,8 @@ 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, @@ -65,15 +67,17 @@ impl InitializeRequestProcessor { // 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 (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, @@ -88,6 +92,7 @@ impl InitializeRequestProcessor { } 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 { @@ -95,27 +100,29 @@ impl InitializeRequestProcessor { 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")); } - // 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. + 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. + } } } } @@ -126,7 +133,7 @@ impl InitializeRequestProcessor { self.rpc_transport, ); set_default_client_residency_requirement(self.config.enforce_residency.value()); - if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() { + if mutates_global_identity && let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() { *suffix = Some(user_agent_suffix); } diff --git a/codex-rs/app-server/src/request_processors/mcp_processor.rs b/codex-rs/app-server/src/request_processors/mcp_processor.rs index 243506f6af..812108a15c 100644 --- a/codex-rs/app-server/src/request_processors/mcp_processor.rs +++ b/codex-rs/app-server/src/request_processors/mcp_processor.rs @@ -286,7 +286,7 @@ impl McpRequestProcessor { .mcp_servers .keys() .cloned() - // Include built-in/plugin MCP servers that are present in the + // 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()) diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index cc5de4058a..125accad82 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -3,6 +3,8 @@ 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; @@ -88,13 +90,14 @@ fn marketplace_plugin_source_to_info(source: MarketplacePluginSource) -> PluginS fn load_shared_plugin_ids_by_local_path( config: &Config, -) -> std::collections::BTreeMap { +) -> Result, JSONRPCErrorError> { codex_core_plugins::remote::load_plugin_share_remote_ids_by_local_path( config.codex_home.as_path(), ) - .unwrap_or_else(|err| { - warn!("failed to load plugin share local path mapping: {err}"); - std::collections::BTreeMap::new() + .map_err(|err| { + internal_error(format!( + "failed to load plugin share local path mapping: {err}" + )) }) } @@ -108,10 +111,11 @@ fn share_context_for_source( .cloned() .map(|remote_plugin_id| PluginShareContext { remote_plugin_id, + discoverability: None, share_url: None, creator_account_user_id: None, creator_name: None, - share_targets: None, + share_principals: None, }), MarketplacePluginSource::Git { .. } => None, } @@ -133,6 +137,62 @@ fn remote_plugin_share_discoverability( } } +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 { @@ -152,6 +212,7 @@ fn remote_plugin_share_targets( } }, principal_id: target.principal_id, + role: remote_plugin_share_target_role(target.role), }, ) .collect() @@ -173,6 +234,7 @@ fn plugin_share_principal_from_remote( } }, principal_id: principal.principal_id, + role: plugin_share_principal_role_from_remote(principal.role), name: principal.name, } } @@ -388,7 +450,7 @@ impl PluginRequestProcessor { 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); + 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)?; @@ -420,6 +482,7 @@ impl PluginRequestProcessor { ); PluginSummary { id: plugin.id, + remote_plugin_id: None, installed: plugin.installed, enabled: plugin.enabled, name: plugin.name, @@ -499,10 +562,25 @@ impl PluginRequestProcessor { } } } + 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, @@ -576,11 +654,56 @@ impl PluginRequestProcessor { .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 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)) + } + Ok(Some(_)) => { + warn!( + remote_plugin_id = %context.remote_plugin_id, + "remote shared plugin detail did not include share principals; returning local share mapping context" + ); + Some(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) @@ -601,6 +724,7 @@ impl PluginRequestProcessor { marketplace_path: outcome.marketplace_path, summary: PluginSummary { id: outcome.plugin.id, + remote_plugin_id: None, name: outcome.plugin.name, share_context, source: marketplace_plugin_source_to_info(outcome.plugin.source), @@ -729,9 +853,17 @@ impl PluginRequestProcessor { } 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 targets", + "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(), @@ -765,11 +897,13 @@ impl PluginRequestProcessor { 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(), @@ -779,6 +913,7 @@ impl PluginRequestProcessor { auth.as_ref(), &remote_plugin_id, remote_plugin_share_targets(share_targets), + remote_plugin_share_update_discoverability(discoverability), ) .await .map_err(|err| { @@ -791,6 +926,7 @@ impl PluginRequestProcessor { .into_iter() .map(plugin_share_principal_from_remote) .collect(), + discoverability: remote_plugin_share_discoverability_to_info(result.discoverability), }) } @@ -813,13 +949,11 @@ impl PluginRequestProcessor { .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, } }) @@ -975,7 +1109,6 @@ impl PluginRequestProcessor { ) })?; 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" ))); @@ -1455,6 +1588,7 @@ fn remote_marketplace_to_info(marketplace: RemoteMarketplace) -> PluginMarketpla fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginSummary { PluginSummary { id: summary.id, + remote_plugin_id: Some(summary.remote_plugin_id), name: summary.name, share_context: summary .share_context @@ -1475,11 +1609,14 @@ fn remote_plugin_share_context_to_info( ) -> PluginShareContext { PluginShareContext { remote_plugin_id: context.remote_plugin_id, + 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_targets: context.share_targets.map(|targets| { - targets + share_principals: context.share_principals.map(|principals| { + principals .into_iter() .map(plugin_share_principal_from_remote) .collect() @@ -1487,6 +1624,22 @@ fn remote_plugin_share_context_to_info( } } +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, diff --git a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs index 0e12e44ce5..57d2575590 100644 --- a/codex-rs/app-server/src/request_processors/thread_goal_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_goal_processor.rs @@ -107,7 +107,7 @@ impl ThreadGoalRequestProcessor { "ephemeral thread does not support goals: {thread_id}" )) })?, - None => find_thread_path_by_id_str( + None => codex_rollout::find_thread_path_by_id_str( &self.config.codex_home, &thread_id.to_string(), self.state_db.as_deref(), @@ -153,15 +153,13 @@ impl ThreadGoalRequestProcessor { .get_thread_goal(thread_id) .await .map_err(|err| invalid_request(err.to_string()))?; - if let Some(goal) = existing_goal.as_ref().filter(|goal| { - goal.objective == objective - && goal.status != codex_state::ThreadGoalStatus::Complete - }) { - let previous_status = ExternalGoalPreviousStatus::Existing(goal.status); + 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()), @@ -198,11 +196,12 @@ impl ThreadGoalRequestProcessor { "cannot update goal for thread {thread_id}: no goal exists" ))); }; - let previous_status = ExternalGoalPreviousStatus::Existing(existing_goal.status); + 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, @@ -272,7 +271,7 @@ impl ThreadGoalRequestProcessor { "ephemeral thread does not support goals: {thread_id}" )) })?, - None => find_thread_path_by_id_str( + None => codex_rollout::find_thread_path_by_id_str( &self.config.codex_home, &thread_id.to_string(), self.state_db.as_deref(), @@ -336,7 +335,7 @@ impl ThreadGoalRequestProcessor { return Ok(state_db); } } else { - find_thread_path_by_id_str( + codex_rollout::find_thread_path_by_id_str( &self.config.codex_home, &thread_id.to_string(), self.state_db.as_deref(), diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index ef44a2b178..7dab206d85 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -12,6 +12,7 @@ pub(super) struct ListenerTaskContext { 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 { @@ -226,12 +227,22 @@ pub(super) async fn ensure_listener_task_running( "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) + thread_state.set_listener(cancel_tx, &conversation, watch_registration) }; let ListenerTaskContext { outgoing, @@ -242,6 +253,7 @@ pub(super) async fn ensure_listener_task_running( thread_list_state_permit, fallback_model_provider, codex_home, + .. } = listener_task_context; let outgoing_for_task = Arc::clone(&outgoing); tokio::spawn(async move { @@ -545,6 +557,10 @@ pub(super) async fn handle_pending_thread_resume_request( 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; @@ -612,7 +628,6 @@ pub(super) async fn handle_pending_thread_resume_request( 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. diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index d83f5e631f..9b5cef069d 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1,4 +1,5 @@ use super::*; +use crate::error_code::method_not_found; const THREAD_LIST_DEFAULT_LIMIT: usize = 25; const THREAD_LIST_MAX_LIMIT: usize = 100; @@ -316,6 +317,7 @@ pub(crate) struct ThreadRequestProcessor { pub(super) thread_goal_processor: ThreadGoalRequestProcessor, pub(super) state_db: Option, pub(super) background_tasks: TaskTracker, + pub(super) skills_watcher: Arc, } impl ThreadRequestProcessor { @@ -334,6 +336,7 @@ impl ThreadRequestProcessor { thread_list_state_permit: Arc, thread_goal_processor: ThreadGoalRequestProcessor, state_db: Option, + skills_watcher: Arc, ) -> Self { Self { auth_manager, @@ -350,6 +353,7 @@ impl ThreadRequestProcessor { thread_goal_processor, state_db, background_tasks: TaskTracker::new(), + skills_watcher, } } @@ -591,6 +595,15 @@ impl ThreadRequestProcessor { .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, @@ -742,6 +755,7 @@ impl ThreadRequestProcessor { 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), } } @@ -839,6 +853,7 @@ impl ThreadRequestProcessor { 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(); @@ -945,6 +960,7 @@ impl ThreadRequestProcessor { 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()) @@ -1039,7 +1055,7 @@ impl ThreadRequestProcessor { .collect() }; let core_dynamic_tool_count = core_dynamic_tools.len(); - + let create_thread_started_at = std::time::Instant::now(); let NewThread { thread_id, thread, @@ -1074,6 +1090,12 @@ impl ThreadRequestProcessor { 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(), @@ -1175,6 +1197,11 @@ impl ThreadRequestProcessor { 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(()) } @@ -2072,7 +2099,9 @@ impl ThreadRequestProcessor { 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}")))?; @@ -2101,7 +2130,7 @@ impl ThreadRequestProcessor { } else { None }; - let turns = reconstruct_thread_turns_for_turns_list( + let mut turns = reconstruct_thread_turns_for_turns_list( &items, self.thread_watch_manager .loaded_status_for_thread(&thread_uuid.to_string()) @@ -2109,6 +2138,41 @@ impl ThreadRequestProcessor { 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(), @@ -2183,9 +2247,13 @@ impl ThreadRequestProcessor { self.thread_manager.subscribe_thread_created() } - pub(crate) async fn connection_initialized(&self, connection_id: ConnectionId) { + pub(crate) async fn connection_initialized( + &self, + connection_id: ConnectionId, + capabilities: ConnectionCapabilities, + ) { self.thread_state_manager - .connection_initialized(connection_id) + .connection_initialized(connection_id, capabilities) .await; } @@ -2278,6 +2346,8 @@ impl ThreadRequestProcessor { 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, @@ -2472,6 +2542,10 @@ impl ThreadRequestProcessor { 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, @@ -2489,7 +2563,6 @@ impl ThreadRequestProcessor { }; 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. @@ -2609,6 +2682,8 @@ impl ThreadRequestProcessor { }; 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() @@ -2683,6 +2758,7 @@ impl ThreadRequestProcessor { emit_thread_goal_update, thread_goal_state_db, include_turns: !params.exclude_turns, + redact_resume_payloads, }), ); if listener_command_tx.send(command).is_err() { @@ -3496,19 +3572,30 @@ fn normalize_thread_turns_status( 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}")), } } @@ -3516,6 +3603,9 @@ fn thread_store_list_error(err: ThreadStoreError) -> JSONRPCErrorError { 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}")) } @@ -3538,6 +3628,7 @@ fn thread_turns_list_history_load_error( 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}" )), @@ -3564,6 +3655,7 @@ fn thread_read_history_load_error( 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}" )), @@ -3579,6 +3671,9 @@ fn conversation_summary_thread_id_read_error( 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) } @@ -3601,6 +3696,9 @@ fn conversation_summary_rollout_path_read_error( ) -> 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(), @@ -3615,6 +3713,9 @@ fn thread_store_write_error(operation: &str, err: ThreadStoreError) -> JSONRPCEr invalid_request(format!("thread not found: {thread_id}")) } ThreadStoreError::InvalidRequest { message } => invalid_request(message), + ThreadStoreError::Unsupported { operation } => { + unsupported_thread_store_operation(operation) + } err => internal_error(format!("failed to {operation}: {err}")), } } @@ -3622,6 +3723,9 @@ fn thread_store_write_error(operation: &str, err: ThreadStoreError) -> JSONRPCEr 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}")), } } @@ -3662,7 +3766,7 @@ pub(crate) fn thread_from_stored_thread( id: thread_id.clone(), session_id: thread_id, forked_from_id: thread.forked_from_id.map(|id| id.to_string()), - preview: thread.first_user_message.unwrap_or(thread.preview), + preview: thread.preview, ephemeral: false, model_provider: if thread.model_provider.is_empty() { fallback_provider.to_string() @@ -3704,7 +3808,7 @@ fn summary_from_stored_thread( ConversationSummary { conversation_id: thread.thread_id, path, - preview: thread.first_user_message.unwrap_or(thread.preview), + 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( @@ -3735,6 +3839,7 @@ 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, @@ -3748,7 +3853,7 @@ fn summary_from_state_db_metadata( git_branch: Option, git_origin_url: Option, ) -> ConversationSummary { - let preview = first_user_message.unwrap_or_default(); + 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); @@ -3782,6 +3887,7 @@ fn summary_from_thread_metadata(metadata: &ThreadMetadata) -> ConversationSummar metadata.id, metadata.rollout_path.clone(), metadata.first_user_message.clone(), + metadata.preview.clone(), metadata .created_at .to_rfc3339_opts(SecondsFormat::Secs, true), 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 index 5642dbbe81..61a31f9c4d 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -1086,6 +1086,7 @@ mod thread_processor_behavior_tests { 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(), @@ -1115,7 +1116,9 @@ mod thread_processor_behavior_tests { let connection = ConnectionId(1); let (cancel_tx, cancel_rx) = oneshot::channel(); - manager.connection_initialized(connection).await; + manager + .connection_initialized(connection, ConnectionCapabilities::default()) + .await; manager .try_ensure_connection_subscribed( thread_id, connection, /*experimental_raw_events*/ false, @@ -1158,8 +1161,12 @@ mod thread_processor_behavior_tests { 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 + .connection_initialized(connection_a, ConnectionCapabilities::default()) + .await; + manager + .connection_initialized(connection_b, ConnectionCapabilities::default()) + .await; manager .try_ensure_connection_subscribed( thread_id, @@ -1203,8 +1210,12 @@ mod thread_processor_behavior_tests { let connection_a = ConnectionId(1); let connection_b = ConnectionId(2); - manager.connection_initialized(connection_a).await; - manager.connection_initialized(connection_b).await; + manager + .connection_initialized(connection_a, ConnectionCapabilities::default()) + .await; + manager + .connection_initialized(connection_b, ConnectionCapabilities::default()) + .await; manager .try_ensure_connection_subscribed( thread_id, @@ -1249,7 +1260,9 @@ mod thread_processor_behavior_tests { let thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?; let connection = ConnectionId(1); - manager.connection_initialized(connection).await; + manager + .connection_initialized(connection, ConnectionCapabilities::default()) + .await; let threads_to_unload = manager.remove_connection(connection).await; assert_eq!(threads_to_unload, Vec::::new()); @@ -1264,4 +1277,79 @@ mod thread_processor_behavior_tests { 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/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index bdc5847b0d..d1dae4ef46 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -13,6 +13,7 @@ pub(crate) struct TurnRequestProcessor { thread_state_manager: ThreadStateManager, thread_watch_manager: ThreadWatchManager, thread_list_state_permit: Arc, + skills_watcher: Arc, } impl TurnRequestProcessor { @@ -29,6 +30,7 @@ impl TurnRequestProcessor { thread_state_manager: ThreadStateManager, thread_watch_manager: ThreadWatchManager, thread_list_state_permit: Arc, + skills_watcher: Arc, ) -> Self { Self { auth_manager, @@ -42,6 +44,7 @@ impl TurnRequestProcessor { thread_state_manager, thread_watch_manager, thread_list_state_permit, + skills_watcher, } } @@ -1087,6 +1090,7 @@ impl TurnRequestProcessor { 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), } } 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/transport.rs b/codex-rs/app-server/src/transport.rs index 8d61ac5f56..adf6b1016e 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport.rs @@ -19,13 +19,12 @@ 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; pub(crate) use codex_app_server_transport::start_control_socket_acceptor; pub(crate) use codex_app_server_transport::start_remote_control; pub(crate) use codex_app_server_transport::start_stdio_connection; -pub(crate) use codex_app_server_transport::start_websocket_acceptor; pub(crate) struct ConnectionState { pub(crate) outbound_initialized: Arc, 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 c2a49d8fa6..81a5b2b401 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -89,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; @@ -522,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, diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index c06ff8599a..754d1f9467 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -12,7 +12,6 @@ use codex_app_server_protocol::GetConversationSummaryParams; use codex_app_server_protocol::GetConversationSummaryResponse; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; -use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_arg0::Arg0DispatchPaths; @@ -46,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 { @@ -114,37 +112,6 @@ 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<()> { - 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"), - }) - .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" - ); - Ok(()) -} - #[tokio::test] async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> Result<()> { let codex_home = TempDir::new()?; 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/command_exec.rs b/codex-rs/app-server/tests/suite/v2/command_exec.rs index 211cec9355..f65503aa9c 100644 --- a/codex-rs/app-server/tests/suite/v2/command_exec.rs +++ b/codex-rs/app-server/tests/suite/v2/command_exec.rs @@ -861,10 +861,10 @@ async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminate .as_nanos() ); - let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; + let (mut process, socket_path, _socket_dir) = spawn_websocket_server(codex_home.path()).await?; - let mut ws1 = connect_websocket(bind_addr).await?; - let mut ws2 = connect_websocket(bind_addr).await?; + let mut ws1 = connect_websocket(&socket_path).await?; + let mut ws2 = connect_websocket(&socket_path).await?; send_initialize_request(&mut ws1, /*id*/ 1, "ws_client_one").await?; read_initialize_response(&mut ws1, /*request_id*/ 1).await?; @@ -929,7 +929,7 @@ async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminate process .kill() .await - .context("failed to stop websocket app-server process")?; + .context("failed to stop app-server process")?; Ok(()) } 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..ac322cbdc2 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 @@ -4,8 +4,6 @@ use anyhow::bail; use app_test_support::DISABLE_PLUGIN_STARTUP_TASKS_ARG; use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::to_response; -use base64::Engine; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCError; @@ -18,18 +16,14 @@ use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadLoadedListResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; +use codex_uds::UnixStream; use futures::SinkExt; use futures::StreamExt; -use hmac::Hmac; -use hmac::Mac; -use reqwest::StatusCode; use serde_json::json; -use sha2::Sha256; -use std::net::SocketAddr; use std::path::Path; +use std::path::PathBuf; use std::process::Stdio; use tempfile::TempDir; -use time::OffsetDateTime; use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; use tokio::process::Child; @@ -38,37 +32,29 @@ use tokio::time::Duration; use tokio::time::Instant; use tokio::time::sleep; use tokio::time::timeout; -use tokio_tungstenite::MaybeTlsStream; use tokio_tungstenite::WebSocketStream; -use tokio_tungstenite::connect_async; -use tokio_tungstenite::tungstenite::Error as WsError; +use tokio_tungstenite::client_async; use tokio_tungstenite::tungstenite::Message as WebSocketMessage; -use tokio_tungstenite::tungstenite::client::IntoClientRequest; -use tokio_tungstenite::tungstenite::http::HeaderValue; -use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; -use tokio_tungstenite::tungstenite::http::header::ORIGIN; // macOS and Windows CI can spend tens of seconds starting the app-server test -// binary under Bazel before it accepts JSON-RPC or reports its websocket bind -// address. +// binary under Bazel before it accepts JSON-RPC over the control socket. #[cfg(any(target_os = "macos", windows))] pub(super) const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60); #[cfg(not(any(target_os = "macos", windows)))] pub(super) const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); -pub(super) type WsClient = WebSocketStream>; -type HmacSha256 = Hmac; +pub(super) type WsClient = WebSocketStream; #[tokio::test] -async fn websocket_transport_routes_per_connection_handshake_and_responses() -> Result<()> { +async fn unix_socket_transport_routes_per_connection_handshake_and_responses() -> 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 process, socket_path, _socket_dir) = spawn_websocket_server(codex_home.path()).await?; - let mut ws1 = connect_websocket(bind_addr).await?; - let mut ws2 = connect_websocket(bind_addr).await?; + let mut ws1 = connect_websocket(&socket_path).await?; + let mut ws2 = connect_websocket(&socket_path).await?; send_initialize_request(&mut ws1, /*id*/ 1, "ws_client_one").await?; let first_init = read_response_for_id(&mut ws1, /*id*/ 1).await?; @@ -100,260 +86,20 @@ async fn websocket_transport_routes_per_connection_handshake_and_responses() -> process .kill() .await - .context("failed to stop websocket app-server process")?; + .context("failed to stop app-server process")?; Ok(()) } #[tokio::test] -async fn websocket_transport_serves_health_endpoints_on_same_listener() -> Result<()> { +async fn unix_socket_disconnect_keeps_last_subscribed_thread_loaded_until_idle_timeout() +-> 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 client = reqwest::Client::new(); + let (mut process, socket_path, _socket_dir) = spawn_websocket_server(codex_home.path()).await?; - let readyz = http_get(&client, bind_addr, "/readyz").await?; - assert_eq!(readyz.status(), StatusCode::OK); - - let healthz = http_get(&client, bind_addr, "/healthz").await?; - assert_eq!(healthz.status(), StatusCode::OK); - - let mut ws = connect_websocket(bind_addr).await?; - send_initialize_request(&mut ws, /*id*/ 1, "ws_health_client").await?; - let init = read_response_for_id(&mut ws, /*id*/ 1).await?; - assert_eq!(init.id, RequestId::Integer(1)); - - process - .kill() - .await - .context("failed to stop websocket app-server process")?; - Ok(()) -} - -#[tokio::test] -async fn websocket_transport_rejects_browser_origin_without_auth() -> 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, "ws_loopback_client").await?; - let init = read_response_for_id(&mut ws, /*id*/ 1).await?; - assert_eq!(init.id, RequestId::Integer(1)); - drop(ws); - - assert_websocket_connect_rejected_with_headers( - bind_addr, - /*bearer_token*/ None, - Some("https://evil.example"), - StatusCode::FORBIDDEN, - ) - .await?; - - process - .kill() - .await - .context("failed to stop websocket app-server process")?; - Ok(()) -} - -#[tokio::test] -async fn websocket_transport_rejects_missing_and_invalid_capability_tokens() -> Result<()> { - let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; - let codex_home = TempDir::new()?; - let token_file = codex_home.path().join("app-server-token"); - std::fs::write(&token_file, "super-secret-token\n")?; - create_config_toml(codex_home.path(), &server.uri(), "never")?; - let auth_args = vec![ - "--ws-auth".to_string(), - "capability-token".to_string(), - "--ws-token-file".to_string(), - token_file.display().to_string(), - ]; - - let (mut process, bind_addr) = - spawn_websocket_server_with_args(codex_home.path(), "ws://127.0.0.1:0", &auth_args).await?; - - assert_websocket_connect_rejected(bind_addr, /*bearer_token*/ None).await?; - assert_websocket_connect_rejected(bind_addr, Some("wrong-token")).await?; - - let mut ws = connect_websocket_with_bearer(bind_addr, Some("super-secret-token")).await?; - send_initialize_request(&mut ws, /*id*/ 1, "ws_auth_client").await?; - let init = read_response_for_id(&mut ws, /*id*/ 1).await?; - assert_eq!(init.id, RequestId::Integer(1)); - - process - .kill() - .await - .context("failed to stop websocket app-server process")?; - Ok(()) -} - -#[tokio::test] -async fn websocket_transport_verifies_signed_short_lived_bearer_tokens() -> Result<()> { - let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; - let codex_home = TempDir::new()?; - let shared_secret_file = codex_home.path().join("app-server-signing-secret"); - let shared_secret = "0123456789abcdef0123456789abcdef"; - std::fs::write(&shared_secret_file, format!("{shared_secret}\n"))?; - create_config_toml(codex_home.path(), &server.uri(), "never")?; - let auth_args = vec![ - "--ws-auth".to_string(), - "signed-bearer-token".to_string(), - "--ws-shared-secret-file".to_string(), - shared_secret_file.display().to_string(), - "--ws-issuer".to_string(), - "codex-enroller".to_string(), - "--ws-audience".to_string(), - "codex-app-server".to_string(), - "--ws-max-clock-skew-seconds".to_string(), - "1".to_string(), - ]; - - let (mut process, bind_addr) = - spawn_websocket_server_with_args(codex_home.path(), "ws://127.0.0.1:0", &auth_args).await?; - let expired_token = signed_bearer_token( - shared_secret.as_bytes(), - json!({ - "exp": OffsetDateTime::now_utc().unix_timestamp() - 30, - "iss": "codex-enroller", - "aud": "codex-app-server", - }), - )?; - assert_websocket_connect_rejected(bind_addr, Some(expired_token.as_str())).await?; - - let malformed_token = "not-a-jwt"; - assert_websocket_connect_rejected(bind_addr, Some(malformed_token)).await?; - - let not_yet_valid_token = signed_bearer_token( - shared_secret.as_bytes(), - json!({ - "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, - "nbf": OffsetDateTime::now_utc().unix_timestamp() + 30, - "iss": "codex-enroller", - "aud": "codex-app-server", - }), - )?; - assert_websocket_connect_rejected(bind_addr, Some(not_yet_valid_token.as_str())).await?; - - let wrong_issuer_token = signed_bearer_token( - shared_secret.as_bytes(), - json!({ - "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, - "iss": "someone-else", - "aud": "codex-app-server", - }), - )?; - assert_websocket_connect_rejected(bind_addr, Some(wrong_issuer_token.as_str())).await?; - - let wrong_audience_token = signed_bearer_token( - shared_secret.as_bytes(), - json!({ - "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, - "iss": "codex-enroller", - "aud": "wrong-audience", - }), - )?; - assert_websocket_connect_rejected(bind_addr, Some(wrong_audience_token.as_str())).await?; - - let wrong_signature_token = signed_bearer_token( - b"fedcba9876543210fedcba9876543210", - json!({ - "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, - "iss": "codex-enroller", - "aud": "codex-app-server", - }), - )?; - assert_websocket_connect_rejected(bind_addr, Some(wrong_signature_token.as_str())).await?; - - let valid_token = signed_bearer_token( - shared_secret.as_bytes(), - json!({ - "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, - "iss": "codex-enroller", - "aud": "codex-app-server", - }), - )?; - let mut ws = connect_websocket_with_bearer(bind_addr, Some(valid_token.as_str())).await?; - send_initialize_request(&mut ws, /*id*/ 1, "ws_signed_auth_client").await?; - let init = read_response_for_id(&mut ws, /*id*/ 1).await?; - assert_eq!(init.id, RequestId::Integer(1)); - - process - .kill() - .await - .context("failed to stop websocket app-server process")?; - Ok(()) -} - -#[tokio::test] -async fn websocket_transport_rejects_short_signed_bearer_secret_configuration() -> Result<()> { - let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; - let codex_home = TempDir::new()?; - let shared_secret_file = codex_home.path().join("app-server-signing-secret"); - std::fs::write(&shared_secret_file, "too-short\n")?; - create_config_toml(codex_home.path(), &server.uri(), "never")?; - - let output = run_websocket_server_to_completion_with_args( - codex_home.path(), - "ws://127.0.0.1:0", - &[ - "--ws-auth".to_string(), - "signed-bearer-token".to_string(), - "--ws-shared-secret-file".to_string(), - shared_secret_file.display().to_string(), - ], - ) - .await?; - assert!( - !output.status.success(), - "short shared secret should fail websocket server startup" - ); - let stderr = String::from_utf8(output.stderr).context("stderr should be valid utf-8")?; - assert!( - stderr.contains("must be at least 32 bytes"), - "unexpected stderr: {stderr}" - ); - - Ok(()) -} - -#[tokio::test] -async fn websocket_transport_allows_unauthenticated_non_loopback_startup_by_default() -> 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")?; - - Ok(()) -} - -#[tokio::test] -async fn websocket_disconnect_keeps_last_subscribed_thread_loaded_until_idle_timeout() -> 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 ws1 = connect_websocket(bind_addr).await?; + let mut ws1 = connect_websocket(&socket_path).await?; send_initialize_request(&mut ws1, /*id*/ 1, "ws_thread_owner").await?; read_response_for_id(&mut ws1, /*id*/ 1).await?; @@ -363,7 +109,7 @@ async fn websocket_disconnect_keeps_last_subscribed_thread_loaded_until_idle_tim ws1.close(None).await.context("failed to close websocket")?; drop(ws1); - let mut ws2 = connect_websocket(bind_addr).await?; + let mut ws2 = connect_websocket(&socket_path).await?; send_initialize_request(&mut ws2, /*id*/ 4, "ws_reconnect_client").await?; read_response_for_id(&mut ws2, /*id*/ 4).await?; @@ -372,26 +118,29 @@ async fn websocket_disconnect_keeps_last_subscribed_thread_loaded_until_idle_tim process .kill() .await - .context("failed to stop websocket app-server process")?; + .context("failed to stop app-server process")?; Ok(()) } -pub(super) async fn spawn_websocket_server(codex_home: &Path) -> Result<(Child, SocketAddr)> { - spawn_websocket_server_with_args(codex_home, "ws://127.0.0.1:0", &[]).await -} - -pub(super) async fn spawn_websocket_server_with_args( - codex_home: &Path, - listen_url: &str, - extra_args: &[String], -) -> Result<(Child, SocketAddr)> { +pub(super) async fn spawn_websocket_server(codex_home: &Path) -> Result<(Child, PathBuf, TempDir)> { let program = codex_utils_cargo_bin::cargo_bin("codex-app-server") .context("should find app-server binary")?; + #[cfg(unix)] + let socket_dir = tempfile::Builder::new() + .prefix("cxs-") + .tempdir_in("/tmp") + .context("failed to create short app-server socket temp dir")?; + #[cfg(not(unix))] + let socket_dir = tempfile::Builder::new() + .prefix("cxs-") + .tempdir() + .context("failed to create app-server socket temp dir")?; + let socket_path = socket_dir.path().join("c.sock"); + let listen_url = format!("unix://{}", socket_path.display()); let mut cmd = Command::new(program); cmd.arg("--listen") - .arg(listen_url) + .arg(&listen_url) .arg(DISABLE_PLUGIN_STARTUP_TASKS_ARG) - .args(extra_args) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) @@ -400,194 +149,62 @@ pub(super) async fn spawn_websocket_server_with_args( let mut process = cmd .kill_on_drop(true) .spawn() - .context("failed to spawn websocket app-server process")?; + .context("failed to spawn app-server process")?; let stderr = process .stderr .take() - .context("failed to capture websocket app-server stderr")?; + .context("failed to capture app-server stderr")?; let mut stderr_reader = BufReader::new(stderr).lines(); - let deadline = Instant::now() + DEFAULT_READ_TIMEOUT; - let bind_addr = loop { - let line = timeout( - deadline.saturating_duration_since(Instant::now()), - stderr_reader.next_line(), - ) - .await - .context("timed out waiting for websocket app-server to report bound websocket address")? - .context("failed to read websocket app-server stderr")? - .context("websocket app-server exited before reporting bound websocket address")?; - eprintln!("[websocket app-server stderr] {line}"); - - let stripped_line = { - let mut stripped = String::with_capacity(line.len()); - let mut chars = line.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '\u{1b}' && matches!(chars.peek(), Some(&'[')) { - chars.next(); - for next in chars.by_ref() { - if ('@'..='~').contains(&next) { - break; - } - } - continue; - } - stripped.push(ch); - } - stripped - }; - - if let Some(bind_addr) = stripped_line - .split_whitespace() - .find_map(|token| token.strip_prefix("ws://")) - .and_then(|addr| addr.parse::().ok()) - { - break bind_addr; - } - }; - tokio::spawn(async move { while let Ok(Some(line)) = stderr_reader.next_line().await { - eprintln!("[websocket app-server stderr] {line}"); + eprintln!("[app-server stderr] {line}"); } }); - Ok((process, bind_addr)) -} - -pub(super) async fn connect_websocket(bind_addr: SocketAddr) -> Result { - connect_websocket_with_bearer(bind_addr, /*bearer_token*/ None).await -} - -pub(super) async fn connect_websocket_with_bearer( - bind_addr: SocketAddr, - bearer_token: Option<&str>, -) -> Result { - let url = format!("ws://{}", connectable_bind_addr(bind_addr)); - let request = websocket_request(url.as_str(), bearer_token, /*origin*/ None)?; let deadline = Instant::now() + DEFAULT_READ_TIMEOUT; loop { - match connect_async(request.clone()).await { - Ok((stream, _response)) => return Ok(stream), - Err(err) => { - if Instant::now() >= deadline { - bail!("failed to connect websocket to {url}: {err}"); - } - sleep(Duration::from_millis(50)).await; - } + if socket_path.exists() { + return Ok((process, socket_path, socket_dir)); } - } -} - -async fn assert_websocket_connect_rejected( - bind_addr: SocketAddr, - bearer_token: Option<&str>, -) -> Result<()> { - assert_websocket_connect_rejected_with_headers( - bind_addr, - bearer_token, - /*origin*/ None, - StatusCode::UNAUTHORIZED, - ) - .await -} - -async fn assert_websocket_connect_rejected_with_headers( - bind_addr: SocketAddr, - bearer_token: Option<&str>, - origin: Option<&str>, - expected_status: StatusCode, -) -> Result<()> { - let url = format!("ws://{}", connectable_bind_addr(bind_addr)); - let request = websocket_request(url.as_str(), bearer_token, origin)?; - - match connect_async(request).await { - Ok((_stream, response)) => { + if let Some(status) = process.try_wait()? { + bail!("app-server exited before creating control socket: {status}"); + } + if Instant::now() >= deadline { bail!( - "expected websocket handshake rejection, got {}", - response.status() - ) + "timed out waiting for app-server control socket at {}", + socket_path.display() + ); } - Err(WsError::Http(response)) => { - assert_eq!(response.status(), expected_status); - Ok(()) - } - Err(err) => bail!("expected http rejection during websocket handshake: {err}"), + sleep(Duration::from_millis(50)).await; } } -async fn run_websocket_server_to_completion_with_args( - codex_home: &Path, - listen_url: &str, - extra_args: &[String], -) -> Result { - let program = codex_utils_cargo_bin::cargo_bin("codex-app-server") - .context("should find app-server binary")?; - let mut cmd = Command::new(program); - cmd.arg("--listen") - .arg(listen_url) - .arg(DISABLE_PLUGIN_STARTUP_TASKS_ARG) - .args(extra_args) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .env("CODEX_HOME", codex_home) - .env("RUST_LOG", "warn"); - timeout(DEFAULT_READ_TIMEOUT, cmd.output()) - .await - .context("timed out waiting for websocket app-server to exit")? - .context("failed to run websocket app-server") -} - -async fn http_get( - client: &reqwest::Client, - bind_addr: SocketAddr, - path: &str, -) -> Result { - let connectable_bind_addr = connectable_bind_addr(bind_addr); +pub(super) async fn connect_websocket(socket_path: &Path) -> Result { let deadline = Instant::now() + DEFAULT_READ_TIMEOUT; loop { - match client - .get(format!("http://{connectable_bind_addr}{path}")) - .send() - .await - .with_context(|| format!("failed to GET http://{connectable_bind_addr}{path}")) - { - Ok(response) => return Ok(response), + match UnixStream::connect(socket_path).await { + Ok(stream) => match client_async("ws://localhost/rpc", stream).await { + Ok((websocket, _response)) => return Ok(websocket), + Err(err) => { + if Instant::now() >= deadline { + bail!( + "failed to upgrade websocket over {}: {err}", + socket_path.display() + ); + } + } + }, Err(err) => { if Instant::now() >= deadline { - bail!("failed to GET http://{connectable_bind_addr}{path}: {err}"); + bail!("failed to connect to {}: {err}", socket_path.display()); } - sleep(Duration::from_millis(50)).await; } } + sleep(Duration::from_millis(50)).await; } } -fn websocket_request( - url: &str, - bearer_token: Option<&str>, - origin: Option<&str>, -) -> Result> { - let mut request = url - .into_client_request() - .context("failed to create websocket request")?; - if let Some(bearer_token) = bearer_token { - request.headers_mut().insert( - AUTHORIZATION, - HeaderValue::from_str(&format!("Bearer {bearer_token}")) - .context("invalid bearer token header")?, - ); - } - if let Some(origin) = origin { - request.headers_mut().insert( - ORIGIN, - HeaderValue::from_str(origin).context("invalid origin header")?, - ); - } - Ok(request) -} - pub(super) async fn send_initialize_request( stream: &mut WsClient, id: i64, @@ -850,25 +467,3 @@ stream_max_retries = 0 ), ) } - -fn connectable_bind_addr(bind_addr: SocketAddr) -> SocketAddr { - match bind_addr { - SocketAddr::V4(addr) if addr.ip().is_unspecified() => { - SocketAddr::from(([127, 0, 0, 1], addr.port())) - } - SocketAddr::V6(addr) if addr.ip().is_unspecified() => { - SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], addr.port())) - } - _ => bind_addr, - } -} - -fn signed_bearer_token(shared_secret: &[u8], claims: serde_json::Value) -> Result { - let header_segment = URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#); - let claims_segment = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims)?); - let payload = format!("{header_segment}.{claims_segment}"); - let mut mac = HmacSha256::new_from_slice(shared_secret).context("failed to create hmac")?; - mac.update(payload.as_bytes()); - let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); - Ok(format!("{payload}.{signature}")) -} 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..dcc7b60164 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 @@ -32,12 +32,13 @@ use wiremock::matchers::method; use wiremock::matchers::path_regex; #[tokio::test] -async fn websocket_transport_ctrl_c_waits_for_running_turn_before_exit() -> Result<()> { +async fn unix_socket_transport_ctrl_c_waits_for_running_turn_before_exit() -> Result<()> { let GracefulCtrlCFixture { _codex_home, _server, mut process, mut ws, + .. } = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?; send_sigint(&process)?; @@ -57,12 +58,13 @@ async fn websocket_transport_ctrl_c_waits_for_running_turn_before_exit() -> Resu } #[tokio::test] -async fn websocket_transport_second_ctrl_c_forces_exit_while_turn_running() -> Result<()> { +async fn unix_socket_transport_second_ctrl_c_forces_exit_while_turn_running() -> Result<()> { let GracefulCtrlCFixture { _codex_home, _server, mut process, mut ws, + .. } = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?; send_sigint(&process)?; @@ -83,12 +85,13 @@ async fn websocket_transport_second_ctrl_c_forces_exit_while_turn_running() -> R } #[tokio::test] -async fn websocket_transport_sigterm_waits_for_running_turn_before_exit() -> Result<()> { +async fn unix_socket_transport_sigterm_waits_for_running_turn_before_exit() -> Result<()> { let GracefulCtrlCFixture { _codex_home, _server, mut process, mut ws, + .. } = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?; send_sigterm(&process)?; @@ -108,12 +111,13 @@ async fn websocket_transport_sigterm_waits_for_running_turn_before_exit() -> Res } #[tokio::test] -async fn websocket_transport_second_sigterm_forces_exit_while_turn_running() -> Result<()> { +async fn unix_socket_transport_second_sigterm_forces_exit_while_turn_running() -> Result<()> { let GracefulCtrlCFixture { _codex_home, _server, mut process, mut ws, + .. } = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?; send_sigterm(&process)?; @@ -133,8 +137,38 @@ 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, + _socket_dir: TempDir, _server: wiremock::MockServer, process: Child, ws: WsClient, @@ -153,8 +187,8 @@ async fn start_ctrl_c_restart_fixture(turn_delay: Duration) -> Result Result 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() - .context("websocket app-server process has no pid")?; + let pid = process.id().context("app-server process has no pid")?; let status = StdCommand::new("kill") .arg(signal) .arg(pid.to_string()) @@ -267,7 +304,7 @@ async fn wait_for_process_exit_within( timeout(window, process.wait()) .await .context(timeout_context)? - .context("failed waiting for websocket app-server process exit") + .context("failed waiting for app-server process exit") } async fn expect_websocket_disconnect(stream: &mut WsClient) -> 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/hooks_list.rs b/codex-rs/app-server/tests/suite/v2/hooks_list.rs index 623896626c..f3ce4cbf2d 100644 --- a/codex-rs/app-server/tests/suite/v2/hooks_list.rs +++ b/codex-rs/app-server/tests/suite/v2/hooks_list.rs @@ -53,6 +53,7 @@ fn command_hook_hash( 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), 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/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 8e13df7825..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)] 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 ea8294671b..6576ba87b9 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -13,8 +13,7 @@ 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::PluginSharePrincipal; -use codex_app_server_protocol::PluginSharePrincipalType; +use codex_app_server_protocol::PluginShareDiscoverability; use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::RequestId; @@ -241,6 +240,7 @@ 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, name: "valid-plugin".to_string(), share_context: None, source: PluginSource::Local { @@ -530,6 +530,7 @@ 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, name: "valid-plugin".to_string(), share_context: None, source: PluginSource::Local { @@ -563,6 +564,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab }, PluginSummary { id: "missing-plugin@alternate-marketplace".to_string(), + remote_plugin_id: None, name: "missing-plugin".to_string(), share_context: None, source: PluginSource::Local { @@ -689,15 +691,17 @@ async fn plugin_list_returns_share_context_for_shared_local_plugin() -> Result<( .flat_map(|marketplace| marketplace.plugins.iter()) .find(|plugin| plugin.name == "demo-plugin") .expect("expected demo-plugin entry"); + assert_eq!(plugin.remote_plugin_id, None); 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.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_targets, None); + assert_eq!(share_context.share_principals, None); Ok(()) } @@ -1141,6 +1145,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()?; @@ -1346,11 +1468,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?; @@ -1511,9 +1629,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); @@ -1607,12 +1726,14 @@ async fn plugin_list_fetches_workspace_directory_kind_without_remote_plugin_flag "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; @@ -1646,6 +1767,14 @@ async fn plugin_list_fetches_workspace_directory_kind_without_remote_plugin_flag 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); @@ -1680,16 +1809,21 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { AuthCredentialsStoreMode::File, )?; - let shared_plugin_body = workspace_remote_plugin_page_body( - "plugins~Plugin_22222222222222222222222222222222", - "shared-linear", - "Shared Linear", - /*enabled*/ None, - ); + 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 workspace_installed_body = workspace_remote_plugin_page_body( "plugins~Plugin_22222222222222222222222222222222", "shared-linear", "Shared Linear", + "PRIVATE", /*enabled*/ Some(true), ); mount_shared_workspace_plugins(&server, &shared_plugin_body).await; @@ -1723,6 +1857,11 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { Some("Shared with me") ); assert_eq!(marketplace.plugins.len(), 1); + assert_eq!(marketplace.plugins[0].id, "shared-linear@shared-with-me"); + 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); @@ -1734,6 +1873,10 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { share_context.remote_plugin_id, "plugins~Plugin_22222222222222222222222222222222" ); + assert_eq!( + share_context.discoverability, + Some(PluginShareDiscoverability::Private) + ); assert_eq!( share_context.creator_account_user_id.as_deref(), Some("user-gavin__account-123") @@ -1743,14 +1886,7 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { share_context.share_url.as_deref(), Some("https://chatgpt.example/plugins/share/share-key-1") ); - assert_eq!( - share_context.share_targets, - Some(vec![PluginSharePrincipal { - principal_type: PluginSharePrincipalType::User, - principal_id: "user-ada__account-123".to_string(), - name: "Ada".to_string(), - }]) - ); + assert_eq!(share_context.share_principals, None); wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?; Ok(()) } @@ -2263,6 +2399,7 @@ 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 @@ -2275,6 +2412,7 @@ fn workspace_remote_plugin_page_body( "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", 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 16924b0218..62294b2f95 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -18,12 +18,15 @@ 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; @@ -206,9 +209,10 @@ plugins = true let response: PluginReadResponse = to_response(response)?; assert_eq!(response.plugin.marketplace_name, "chatgpt-global"); + 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.source, PluginSource::Remote); @@ -237,6 +241,7 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result< "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", @@ -309,6 +314,12 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result< .await??; let response: PluginReadResponse = to_response(response)?; + assert_eq!(response.plugin.marketplace_name, "shared-with-me"); + assert_eq!(response.plugin.summary.id, "shared-linear@shared-with-me"); + assert_eq!( + response.plugin.summary.remote_plugin_id.as_deref(), + Some("plugins~Plugin_11111111111111111111111111111111") + ); let share_context = response .plugin .summary @@ -319,6 +330,10 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result< share_context.remote_plugin_id, "plugins~Plugin_11111111111111111111111111111111" ); + assert_eq!( + share_context.discoverability, + Some(PluginShareDiscoverability::Private) + ); assert_eq!( share_context.creator_account_user_id.as_deref(), Some("user-gavin__account-123") @@ -329,12 +344,21 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result< Some("https://chatgpt.example/plugins/share/share-key-1") ); assert_eq!( - share_context.share_targets, - Some(vec![PluginSharePrincipal { - principal_type: PluginSharePrincipalType::User, - principal_id: "user-ada__account-123".to_string(), - name: "Ada".to_string(), - }]) + 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(), + }, + ]) ); Ok(()) } @@ -465,9 +489,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); @@ -751,6 +776,19 @@ enabled = true 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", @@ -764,7 +802,121 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<( .join("demo-plugin/.codex-plugin/plugin.json"), r#"{"name":"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)?; + 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": { + "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); + 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.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_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)?; @@ -788,6 +940,7 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<( .await??; let response: PluginReadResponse = to_response(response)?; + assert_eq!(response.plugin.summary.remote_plugin_id, None); let share_context = response .plugin .summary @@ -795,10 +948,60 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<( .as_ref() .expect("expected share context"); assert_eq!(share_context.remote_plugin_id, "plugins_123"); + 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_targets, 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(()) } 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 09b39ca241..46f3dceb5e 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -14,9 +14,11 @@ 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; @@ -160,7 +162,8 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { PluginShareListResponse { data: vec![PluginShareListItem { plugin: PluginSummary { - id: "plugins_123".to_string(), + id: "demo-plugin@shared-with-me".to_string(), + remote_plugin_id: Some("plugins_123".to_string()), name: "demo-plugin".to_string(), share_context: Some(expected_share_context("plugins_123")), source: PluginSource::Remote, @@ -172,7 +175,6 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { 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), }], } @@ -219,15 +221,17 @@ async fn plugin_share_save_forwards_access_policy() -> Result<()> { .and(body_json(json!({ "file_id": "file_123", "etag": "\"upload_etag_123\"", - "discoverability": "PRIVATE", + "discoverability": "UNLISTED", "share_targets": [ { "principal_type": "user", "principal_id": "user-1", + "role": "editor", }, { "principal_type": "workspace", - "principal_id": "workspace-1", + "principal_id": "account-123", + "role": "reader", }, ], }))) @@ -247,15 +251,12 @@ async fn plugin_share_save_forwards_access_policy() -> Result<()> { "plugin/share/save", Some(json!({ "pluginPath": expected_plugin_path, - "discoverability": "PRIVATE", + "discoverability": "UNLISTED", "shareTargets": [ { "principalType": "user", "principalId": "user-1", - }, - { - "principalType": "workspace", - "principalId": "workspace-1", + "role": "editor", }, ], })), @@ -279,6 +280,126 @@ async fn plugin_share_save_forwards_access_policy() -> Result<()> { 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_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()?; @@ -308,6 +429,7 @@ async fn plugin_share_save_rejects_access_policy_for_existing_plugin() -> Result { "principalType": "user", "principalId": "user-1", + "role": "reader", }, ], })), @@ -323,7 +445,7 @@ async fn plugin_share_save_rejects_access_policy_for_existing_plugin() -> Result 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 targets" + "discoverability and shareTargets are only supported when creating a plugin share; use plugin/share/updateTargets to update share settings" ); Ok(()) } @@ -385,7 +507,8 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { PluginShareListResponse { data: vec![PluginShareListItem { plugin: PluginSummary { - id: "plugins_123".to_string(), + id: "demo-plugin@shared-with-me".to_string(), + remote_plugin_id: Some("plugins_123".to_string()), name: "demo-plugin".to_string(), share_context: Some(expected_share_context("plugins_123")), source: PluginSource::Remote, @@ -397,7 +520,6 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { interface: Some(expected_plugin_interface()), keywords: Vec::new(), }, - share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), local_plugin_path: None, }], } @@ -420,25 +542,46 @@ async fn plugin_share_update_targets_updates_share_targets() -> Result<()> { )?; Mock::given(method("PUT")) - .and(path("/backend-api/public/plugins/plugins_123/shares")) + .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) @@ -451,10 +594,12 @@ async fn plugin_share_update_targets_updates_share_targets() -> Result<()> { "plugin/share/updateTargets", Some(json!({ "remotePluginId": "plugins_123", + "discoverability": "UNLISTED", "shareTargets": [ { "principalType": "user", "principalId": "user-1", + "role": "editor", }, ], })), @@ -471,11 +616,27 @@ async fn plugin_share_update_targets_updates_share_targets() -> Result<()> { assert_eq!( response, PluginShareUpdateTargetsResponse { - principals: vec![PluginSharePrincipal { - principal_type: PluginSharePrincipalType::User, - principal_id: "user-1".to_string(), - name: "Gavin".to_string(), - }], + 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(()) @@ -566,7 +727,8 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { PluginShareListResponse { data: vec![PluginShareListItem { plugin: PluginSummary { - id: "plugins_123".to_string(), + id: "demo-plugin@shared-with-me".to_string(), + remote_plugin_id: Some("plugins_123".to_string()), name: "demo-plugin".to_string(), share_context: Some(expected_share_context("plugins_123")), source: PluginSource::Remote, @@ -578,7 +740,6 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { interface: Some(expected_plugin_interface()), keywords: Vec::new(), }, - share_url: "https://chatgpt.example/plugins/share/share-key-1".to_string(), local_plugin_path: None, }], } @@ -606,7 +767,22 @@ 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": { @@ -662,10 +838,24 @@ fn expected_plugin_interface() -> PluginInterface { fn expected_share_context(plugin_id: &str) -> PluginShareContext { PluginShareContext { remote_plugin_id: plugin_id.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_targets: 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(), + }, + ]), } } 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 5679234d2b..8fdf149a01 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -617,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/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index b04eb12a45..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, 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 cbfeb32870..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( @@ -317,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( @@ -392,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?; @@ -424,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(), @@ -439,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?; @@ -465,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(()) } @@ -517,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?; @@ -549,7 +474,6 @@ async fn skills_list_preserves_requested_cwd_order() -> Result<()> { second_cwd.path().to_path_buf(), ], force_reload: true, - per_cwd_extra_user_roots: None, }) .await?; @@ -571,61 +495,19 @@ async fn skills_list_preserves_requested_cwd_order() -> Result<()> { Ok(()) } -#[tokio::test] -async fn skills_list_ignores_per_cwd_extra_roots_for_unknown_cwd() -> 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 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()], - 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?; - - 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(), requested_cwd.path()); - assert!( - data[0] - .skills - .iter() - .all(|skill| skill.name != "ignored-extra-skill") - ); - Ok(()) -} - #[tokio::test] 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( @@ -642,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( @@ -670,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( @@ -694,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, @@ -751,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_fork.rs b/codex-rs/app-server/tests/suite/v2/thread_fork.rs index 8394f9839e..3eb262bd2b 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_fork.rs @@ -33,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; @@ -51,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<()> { @@ -269,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; @@ -779,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_name_websocket.rs b/codex-rs/app-server/tests/suite/v2/thread_name_websocket.rs index 951e4d74e9..2382d8fe3d 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 @@ -36,11 +36,11 @@ async fn thread_name_updated_broadcasts_for_loaded_threads() -> Result<()> { create_config_toml(codex_home.path(), &server.uri(), "never")?; let conversation_id = create_rollout(codex_home.path(), "2025-01-05T12-00-00")?; - let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; + let (mut process, socket_path, _socket_dir) = spawn_websocket_server(codex_home.path()).await?; let result = async { - let mut ws1 = connect_websocket(bind_addr).await?; - let mut ws2 = connect_websocket(bind_addr).await?; + let mut ws1 = connect_websocket(&socket_path).await?; + let mut ws2 = connect_websocket(&socket_path).await?; initialize_both_clients(&mut ws1, &mut ws2).await?; send_request( @@ -91,7 +91,7 @@ async fn thread_name_updated_broadcasts_for_loaded_threads() -> Result<()> { process .kill() .await - .context("failed to stop websocket app-server process")?; + .context("failed to stop app-server process")?; result } @@ -102,11 +102,11 @@ async fn thread_name_updated_broadcasts_for_not_loaded_threads() -> Result<()> { create_config_toml(codex_home.path(), &server.uri(), "never")?; let conversation_id = create_rollout(codex_home.path(), "2025-01-05T12-05-00")?; - let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; + let (mut process, socket_path, _socket_dir) = spawn_websocket_server(codex_home.path()).await?; let result = async { - let mut ws1 = connect_websocket(bind_addr).await?; - let mut ws2 = connect_websocket(bind_addr).await?; + let mut ws1 = connect_websocket(&socket_path).await?; + let mut ws2 = connect_websocket(&socket_path).await?; initialize_both_clients(&mut ws1, &mut ws2).await?; let renamed = "Stored rename"; @@ -143,7 +143,7 @@ async fn thread_name_updated_broadcasts_for_not_loaded_threads() -> Result<()> { process .kill() .await - .context("failed to stop websocket app-server process")?; + .context("failed to stop app-server process")?; result } 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 0e9a7a9a05..52420c0c80 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -31,6 +31,7 @@ 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; @@ -46,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; @@ -223,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( @@ -238,7 +241,7 @@ async fn thread_turns_list_can_page_backward_and_forward() -> Result<()> { assert_eq!(turn_user_texts(&data), vec!["third", "second"]); assert!( data.iter() - .all(|turn| turn.items_view == TurnItemsView::Full) + .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"); @@ -249,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( @@ -267,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( @@ -280,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()?; @@ -334,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? @@ -583,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( @@ -607,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( @@ -963,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( @@ -983,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; @@ -1068,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!( @@ -1084,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() @@ -1100,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, } 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 7d7de25515..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; @@ -51,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; @@ -77,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; @@ -95,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 { @@ -377,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; @@ -618,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; @@ -745,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; @@ -2928,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_shell_command.rs b/codex-rs/app-server/tests/suite/v2/thread_shell_command.rs index eebc4077df..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 @@ -151,6 +151,7 @@ async fn thread_shell_command_history_responses_exclude_persisted_command_execut cursor: None, limit: None, sort_direction: Some(SortDirection::Asc), + items_view: None, }) .await?; let turns_list_resp: JSONRPCResponse = timeout( 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/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index e5c5c5adbb..524b795b81 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -54,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; @@ -375,13 +376,19 @@ async fn turn_start_sends_service_tier_id_to_model_request() -> Result<()> { "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("mock-model".to_string()), + model: Some(service_tier_model.id.clone()), ..Default::default() }) .await?; @@ -392,7 +399,6 @@ async fn turn_start_sends_service_tier_id_to_model_request() -> Result<()> { .await??; let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; - let service_tier_id = "experimental-tier-id".to_string(); let turn_req = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id, @@ -1985,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)?; @@ -1994,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 [ @@ -2018,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"]), @@ -2038,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 abe223f1d9..1aa429e0d3 100644 --- a/codex-rs/apply-patch/src/invocation.rs +++ b/codex-rs/apply-patch/src/invocation.rs @@ -151,78 +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. /// diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 99a63e3ace..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)] 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/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/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/builtin-mcps/BUILD.bazel b/codex-rs/builtin-mcps/BUILD.bazel deleted file mode 100644 index 9c738d636b..0000000000 --- a/codex-rs/builtin-mcps/BUILD.bazel +++ /dev/null @@ -1,6 +0,0 @@ -load("//:defs.bzl", "codex_rust_crate") - -codex_rust_crate( - name = "builtin-mcps", - crate_name = "codex_builtin_mcps", -) diff --git a/codex-rs/builtin-mcps/Cargo.toml b/codex-rs/builtin-mcps/Cargo.toml deleted file mode 100644 index ee61f96750..0000000000 --- a/codex-rs/builtin-mcps/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -edition.workspace = true -license.workspace = true -name = "codex-builtin-mcps" -version.workspace = true - -[lib] -name = "codex_builtin_mcps" -path = "src/lib.rs" - -[lints] -workspace = true - -[dependencies] -anyhow = { workspace = true } -codex-memories-mcp = { workspace = true } -codex-utils-absolute-path = { workspace = true } -tokio = { workspace = true, features = ["io-util"] } - -[dev-dependencies] -pretty_assertions = { workspace = true } diff --git a/codex-rs/builtin-mcps/src/lib.rs b/codex-rs/builtin-mcps/src/lib.rs deleted file mode 100644 index cf5cb74882..0000000000 --- a/codex-rs/builtin-mcps/src/lib.rs +++ /dev/null @@ -1,101 +0,0 @@ -//! Built-in MCP servers shipped with Codex. -//! -//! This crate owns the catalog of product-owned MCP servers and the small -//! amount of server-specific dispatch needed to run them. Runtime placement is -//! chosen by `codex-mcp`; built-ins should not be flattened into user-facing -//! MCP server config just to make them launchable. - -use std::path::Path; - -use tokio::io::AsyncRead; -use tokio::io::AsyncWrite; - -pub const MEMORIES_MCP_SERVER_NAME: &str = "memories"; - -/// Product-owned MCP servers that Codex can provide without user config. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum BuiltinMcpServer { - Memories, -} - -#[derive(Debug, Clone, Copy)] -struct BuiltinMcpServerMetadata { - name: &'static str, - supports_parallel_tool_calls: bool, - pollutes_memory: bool, -} - -impl BuiltinMcpServer { - const fn metadata(self) -> BuiltinMcpServerMetadata { - match self { - Self::Memories => BuiltinMcpServerMetadata { - name: MEMORIES_MCP_SERVER_NAME, - supports_parallel_tool_calls: true, - pollutes_memory: false, - }, - } - } - - pub const fn name(self) -> &'static str { - self.metadata().name - } - - pub const fn supports_parallel_tool_calls(self) -> bool { - self.metadata().supports_parallel_tool_calls - } - - pub const fn pollutes_memory(self) -> bool { - self.metadata().pollutes_memory - } - - pub async fn serve(self, codex_home: &Path, transport: T) -> anyhow::Result<()> - where - T: AsyncRead + AsyncWrite + Send + 'static, - { - match self { - Self::Memories => { - let codex_home = codex_utils_absolute_path::AbsolutePathBuf::try_from(codex_home)?; - codex_memories_mcp::run_server(&codex_home, transport).await - } - } - } -} - -#[derive(Debug, Clone, Copy)] -pub struct BuiltinMcpServerOptions { - pub memories_enabled: bool, -} - -pub fn enabled_builtin_mcp_servers(options: BuiltinMcpServerOptions) -> Vec { - let mut servers = Vec::new(); - if options.memories_enabled { - servers.push(BuiltinMcpServer::Memories); - } - servers -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - - #[test] - fn enabled_builtin_mcp_servers_adds_memories_when_enabled() { - assert_eq!( - enabled_builtin_mcp_servers(BuiltinMcpServerOptions { - memories_enabled: true, - }), - vec![BuiltinMcpServer::Memories] - ); - } - - #[test] - fn enabled_builtin_mcp_servers_omits_memories_when_disabled() { - assert_eq!( - enabled_builtin_mcp_servers(BuiltinMcpServerOptions { - memories_enabled: false, - }), - Vec::::new() - ); - } -} 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/main.rs b/codex-rs/cli/src/main.rs index dbe6b7605c..fba2c479cd 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; @@ -126,7 +129,7 @@ enum Subcommand { /// [experimental] Run the app server or related tooling. AppServer(AppServerCommand), - /// [experimental] Start a headless app-server with remote control enabled. + /// [experimental] Ensure the app-server daemon is running with remote control enabled. RemoteControl, /// Launch the Codex desktop app (opens the app installer if missing). @@ -417,7 +420,7 @@ struct AppServerCommand { subcommand: Option, /// Transport endpoint URL. Supported values: `stdio://` (default), - /// `unix://`, `unix://PATH`, `ws://IP:PORT`, `off`. + /// `unix://`, `unix://PATH`, `off`. #[arg( long = "listen", value_name = "URL", @@ -442,9 +445,6 @@ struct AppServerCommand { /// See https://developers.openai.com/codex/config-advanced/#metrics for more details. #[arg(long = "analytics-default-enabled")] analytics_default_enabled: bool, - - #[command(flatten)] - auth: codex_app_server::AppServerWebsocketAuthArgs, } #[derive(Debug, Parser)] @@ -469,6 +469,9 @@ struct ExecServerCommand { #[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), @@ -483,6 +486,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. @@ -490,6 +527,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 @@ -728,14 +772,6 @@ struct FeatureSetArgs { feature: String, } -const REMOTE_CONTROL_FEATURE_OVERRIDE: &str = "features.remote_control=true"; - -fn enable_remote_control_for_invocation(config_overrides: &mut CliConfigOverrides) { - config_overrides - .raw_overrides - .push(REMOTE_CONTROL_FEATURE_OVERRIDE.to_string()); -} - fn stage_str(stage: Stage) -> &'static str { match stage { Stage::UnderDevelopment => "under development", @@ -853,7 +889,6 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { subcommand, listen, analytics_default_enabled, - auth, } = app_server_cli; reject_remote_mode_for_app_server_subcommand( root_remote.as_deref(), @@ -863,7 +898,6 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { match subcommand { None => { let transport = listen; - let auth = auth.try_into_settings()?; codex_app_server::run_main_with_transport( arg0_paths.clone(), root_config_overrides, @@ -871,10 +905,44 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { analytics_default_enabled, transport, codex_protocol::protocol::SessionSource::VSCode, - auth, ) .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, @@ -913,17 +981,8 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { root_remote_auth_token_env.as_deref(), "remote-control", )?; - enable_remote_control_for_invocation(&mut root_config_overrides); - codex_app_server::run_main_with_transport( - arg0_paths.clone(), - root_config_overrides, - codex_config::LoaderOverrides::default(), - /*default_analytics_enabled*/ false, - codex_app_server::AppServerTransport::Off, - codex_protocol::protocol::SessionSource::Cli, - codex_app_server::AppServerWebsocketAuthSettings::default(), - ) - .await?; + 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)) => { @@ -1547,6 +1606,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", @@ -1557,6 +1630,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, @@ -2305,24 +2392,6 @@ mod tests { assert!(app_server.analytics_default_enabled); } - #[test] - fn remote_control_override_is_appended_after_root_toggles() { - let mut config_overrides = CliConfigOverrides::default(); - config_overrides - .raw_overrides - .push("features.remote_control=false".to_string()); - - enable_remote_control_for_invocation(&mut config_overrides); - - assert_eq!( - config_overrides.raw_overrides, - vec![ - "features.remote_control=false".to_string(), - REMOTE_CONTROL_FEATURE_OVERRIDE.to_string(), - ] - ); - } - #[test] fn reject_remote_flag_for_remote_control() { let cli = MultitoolCli::try_parse_from([ @@ -2452,16 +2521,14 @@ mod tests { } #[test] - fn app_server_listen_websocket_url_parses() { - let app_server = app_server_from_args( - ["codex", "app-server", "--listen", "ws://127.0.0.1:4500"].as_ref(), - ); - assert_eq!( - app_server.listen, - codex_app_server::AppServerTransport::WebSocket { - bind_address: "127.0.0.1:4500".parse().expect("valid socket address"), - } - ); + fn app_server_listen_websocket_url_fails_to_parse() { + let parse_result = MultitoolCli::try_parse_from([ + "codex", + "app-server", + "--listen", + "ws://127.0.0.1:4500", + ]); + assert!(parse_result.is_err()); } #[test] @@ -2524,6 +2591,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 = @@ -2553,58 +2684,17 @@ mod tests { } #[test] - fn app_server_capability_token_flags_parse() { - let app_server = app_server_from_args( - [ - "codex", - "app-server", - "--ws-auth", - "capability-token", - "--ws-token-file", - "/tmp/codex-token", - ] - .as_ref(), - ); - assert_eq!( - app_server.auth.ws_auth, - Some(codex_app_server::WebsocketAuthCliMode::CapabilityToken) - ); - assert_eq!( - app_server.auth.ws_token_file, - Some(PathBuf::from("/tmp/codex-token")) - ); - } - - #[test] - fn app_server_signed_bearer_flags_parse() { - let app_server = app_server_from_args( - [ - "codex", - "app-server", - "--ws-auth", - "signed-bearer-token", - "--ws-shared-secret-file", - "/tmp/codex-secret", - "--ws-issuer", - "issuer", - "--ws-audience", - "audience", - "--ws-max-clock-skew-seconds", - "9", - ] - .as_ref(), - ); - assert_eq!( - app_server.auth.ws_auth, - Some(codex_app_server::WebsocketAuthCliMode::SignedBearerToken) - ); - assert_eq!( - app_server.auth.ws_shared_secret_file, - Some(PathBuf::from("/tmp/codex-secret")) - ); - assert_eq!(app_server.auth.ws_issuer.as_deref(), Some("issuer")); - assert_eq!(app_server.auth.ws_audience.as_deref(), Some("audience")); - assert_eq!(app_server.auth.ws_max_clock_skew_seconds, Some(9)); + 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] 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..360e99a217 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; @@ -1399,6 +1400,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), + }, + )]), + }), }, )]), }), 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/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/requests/headers.rs b/codex-rs/codex-api/src/requests/headers.rs index d91d2a2bf1..ed5a5ad647 100644 --- a/codex-rs/codex-api/src/requests/headers.rs +++ b/codex-rs/codex-api/src/requests/headers.rs @@ -6,9 +6,11 @@ pub fn build_session_headers(session_id: Option, thread_id: Option 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") 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 51d4a83578..184505eb55 100644 --- a/codex-rs/codex-client/Cargo.toml +++ b/codex-rs/codex-client/Cargo.toml @@ -35,3 +35,6 @@ pretty_assertions = { workspace = true } rcgen = { workspace = true } tempfile = { workspace = true } tracing-subscriber = { workspace = true } + +[lib] +doctest = false 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 9e957665c3..99dc839052 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 @@ -17,7 +18,6 @@ async-channel = { workspace = true } codex-async-utils = { workspace = true } codex-api = { workspace = true } codex-client = { workspace = true } -codex-builtin-mcps = { workspace = true } codex-config = { workspace = true } codex-exec-server = { workspace = true } codex-login = { workspace = true } diff --git a/codex-rs/codex-mcp/src/builtin.rs b/codex-rs/codex-mcp/src/builtin.rs deleted file mode 100644 index 9441b644dd..0000000000 --- a/codex-rs/codex-mcp/src/builtin.rs +++ /dev/null @@ -1,39 +0,0 @@ -use std::io; -use std::path::PathBuf; - -use codex_builtin_mcps::BuiltinMcpServer; -use codex_rmcp_client::InProcessTransportFactory; -use futures::FutureExt; -use futures::future::BoxFuture; - -#[derive(Clone)] -pub(crate) struct BuiltinMcpServerFactory { - server: BuiltinMcpServer, - codex_home: PathBuf, -} - -impl BuiltinMcpServerFactory { - pub(crate) fn new(server: BuiltinMcpServer, codex_home: PathBuf) -> Self { - Self { server, codex_home } - } -} - -impl InProcessTransportFactory for BuiltinMcpServerFactory { - fn open(&self) -> BoxFuture<'static, io::Result> { - let server = self.server; - let codex_home = self.codex_home.clone(); - async move { - let (client_transport, server_transport) = tokio::io::duplex(64 * 1024); - tokio::spawn(async move { - if let Err(err) = server.serve(&codex_home, server_transport).await { - tracing::warn!( - server = server.name(), - "built-in MCP server exited: {err:#}" - ); - } - }); - Ok(client_transport) - } - .boxed() - } -} diff --git a/codex-rs/codex-mcp/src/codex_apps.rs b/codex-rs/codex-mcp/src/codex_apps.rs index 5191c9fa66..f70f54c9d3 100644 --- a/codex-rs/codex-mcp/src/codex_apps.rs +++ b/codex-rs/codex-mcp/src/codex_apps.rs @@ -1,4 +1,4 @@ -//! 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, diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index df1d72307a..45f828e822 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -7,7 +7,6 @@ //! `codex-core`. use std::collections::HashMap; -use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -56,6 +55,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; @@ -131,17 +131,6 @@ impl McpConnectionManager { .is_none_or(|metadata| metadata.pollutes_memory) } - pub fn parallel_tool_call_server_names(&self) -> HashSet { - self.server_metadata - .iter() - .filter_map(|(name, metadata)| { - metadata - .supports_parallel_tool_calls - .then_some(name.clone()) - }) - .collect() - } - pub fn is_host_owned_codex_apps_server(&self, server_name: &str) -> bool { self.clients .get(server_name) @@ -182,6 +171,7 @@ impl McpConnectionManager { 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, @@ -256,9 +246,9 @@ impl McpConnectionManager { codex_apps_tools_cache_context, Arc::clone(&tool_plugin_provenance), runtime_environment.clone(), - codex_home.clone(), runtime_auth_provider, provenance, + client_elicitation_capability.clone(), ); clients.insert(server_name.clone(), async_managed_client.clone()); let tx_event = tx_event.clone(); @@ -382,7 +372,11 @@ impl McpConnectionManager { 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)), + ); } normalize_tools_for_model(tools) } @@ -434,11 +428,26 @@ impl McpConnectionManager { .into_iter() .map(|mut tool| { tool.tool = tool_with_model_visible_input_schema(&tool.tool); - tool + self.with_server_metadata(tool) }); 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 /// server name and the value is a vector of resources. pub async fn list_all_resources(&self) -> HashMap> { diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 2203a8fa55..ef7bef4dab 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -11,7 +11,7 @@ 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; @@ -42,6 +42,8 @@ fn create_test_tool(server_name: &str, tool_name: &str) -> ToolInfo { ToolInfo { server_name: server_name.to_string(), server_provenance: Default::default(), + supports_parallel_tool_calls: false, + server_origin: None, callable_name: tool_name.to_string(), callable_namespace: tool_namespace, namespace_description: None, @@ -96,6 +98,18 @@ fn model_tool_names(tools: &[ToolInfo]) -> HashSet { .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!({ @@ -337,17 +351,14 @@ fn test_normalize_tools_long_names_same_server() { let names = model_tool_names(&model_tools); - assert!(names.iter().all(|name| name.display().len() == 64)); + assert!(names.iter().all(|name| model_tool_name_len(name) == 64)); assert!( names .iter() .all(|name| name.namespace.as_deref() == Some("mcp__my_server__")) ); assert!( - names.iter().all(|name| name - .display() - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_')), + names.iter().all(is_code_mode_compatible_tool_name), "model-visible names must be code-mode compatible: {names:?}" ); } @@ -366,10 +377,9 @@ fn test_normalize_tools_sanitizes_invalid_characters() { ToolName::namespaced("mcp__server_one__", "tool_two_three") ); assert_eq!( - format!("{}{}", tool.callable_namespace, tool.callable_name), - model_name.display() + 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"); @@ -378,10 +388,7 @@ fn test_normalize_tools_sanitizes_invalid_characters() { assert_eq!(tool.tool.name, "tool.two-three"); assert!( - model_name - .display() - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_'), + is_code_mode_compatible_tool_name(&model_name), "model-visible name must be code-mode compatible: {model_name:?}" ); } @@ -428,10 +435,7 @@ fn test_normalize_tools_disambiguates_sanitized_namespace_collisions() { assert_eq!(raw_servers, HashSet::from(["basic-server", "basic_server"])); let model_names = model_tool_names(&model_tools); assert!( - model_names.iter().all(|name| name - .display() - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_')), + model_names.iter().all(is_code_mode_compatible_tool_name), "model-visible names must be code-mode compatible: {model_names:?}" ); } @@ -889,16 +893,69 @@ async fn host_owned_codex_apps_server_is_identified_by_client_provenance() { assert!(manager.is_host_owned_codex_apps_server("host_apps")); } +#[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(), + provenance: Default::default(), + }, + ); + + 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] diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index 1919799697..9d1fe223d6 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -23,11 +23,6 @@ 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 codex_builtin_mcps::BuiltinMcpServer; -pub use codex_builtin_mcps::BuiltinMcpServerOptions; -pub use codex_builtin_mcps::MEMORIES_MCP_SERVER_NAME; -pub use codex_builtin_mcps::enabled_builtin_mcp_servers; - pub use mcp::configured_mcp_servers; pub use mcp::effective_mcp_servers; pub use mcp::effective_mcp_servers_from_configured; @@ -57,7 +52,6 @@ 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 builtin; pub(crate) mod codex_apps; pub(crate) mod connection_manager; pub(crate) mod elicitation; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index a4ddf81986..a7d8178565 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -32,6 +32,7 @@ use codex_protocol::mcp::Tool; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::McpAuthStatus; +use rmcp::model::ElicitationCapability; use rmcp::model::ReadResourceRequestParams; use rmcp::model::ReadResourceResult; use serde_json::Value; @@ -108,7 +109,7 @@ pub struct McpPermissionPromptAutoApproveContext { 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, @@ -128,16 +129,15 @@ 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, + /// Client-side elicitation capabilities advertised during MCP initialization. + pub client_elicitation_capability: ElicitationCapability, /// Config-backed MCP servers keyed by server name. /// - /// Product-owned built-ins and runtime-only additions are merged later by - /// [`effective_mcp_servers`]. + /// Runtime-only additions are merged later by [`effective_mcp_servers`]. pub configured_mcp_servers: HashMap, - /// Product-owned built-ins enabled for this runtime config. - pub builtin_mcp_servers: Vec, /// Plugin metadata used to attribute MCP tools/connectors to plugin display names. pub plugin_capability_summaries: Vec, } @@ -256,16 +256,10 @@ pub fn effective_mcp_servers_from_configured( config: &McpConfig, auth: Option<&CodexAuth>, ) -> HashMap { - let mut servers = configured_servers + let servers = configured_servers .into_iter() .map(|(name, server)| (name, EffectiveMcpServer::configured(server))) .collect::>(); - for builtin_server in &config.builtin_mcp_servers { - servers.insert( - builtin_server.name().to_string(), - EffectiveMcpServer::builtin(*builtin_server), - ); - } with_codex_apps_mcp(servers, auth, config) } @@ -303,6 +297,7 @@ pub async fn read_mcp_resource( 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, @@ -371,6 +366,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( 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, diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index ac2b596834..089fdd98a1 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -14,7 +14,6 @@ use codex_protocol::protocol::GranularApprovalConfig; use pretty_assertions::assert_eq; use std::collections::HashMap; use std::path::PathBuf; -use std::sync::Arc; pub(super) fn test_mcp_config(codex_home: PathBuf) -> McpConfig { McpConfig { @@ -29,8 +28,8 @@ pub(super) 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(), - builtin_mcp_servers: Vec::new(), plugin_capability_summaries: Vec::new(), } } @@ -380,53 +379,3 @@ async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() { other => panic!("expected streamable http transport, got {other:?}"), } } - -#[test] -fn effective_mcp_servers_preserve_builtin_runtime_shape() { - let mut config = test_mcp_config(PathBuf::from("/tmp")); - config.builtin_mcp_servers = vec![codex_builtin_mcps::BuiltinMcpServer::Memories]; - - let effective = effective_mcp_servers(&config, /*auth*/ None); - let memories = effective - .get(codex_builtin_mcps::MEMORIES_MCP_SERVER_NAME) - .expect("memories server should exist"); - - assert!(!crate::server::McpServerMetadata::from(memories).pollutes_memory); - assert!(matches!( - memories.launch(), - crate::server::McpServerLaunch::Builtin(codex_builtin_mcps::BuiltinMcpServer::Memories) - )); -} - -#[tokio::test] -async fn builtin_memories_server_runs_in_process() { - let codex_home = tempfile::tempdir().expect("tempdir"); - let mut config = test_mcp_config(codex_home.path().to_path_buf()); - config.builtin_mcp_servers = vec![codex_builtin_mcps::BuiltinMcpServer::Memories]; - - let snapshot = collect_mcp_server_status_snapshot_with_detail( - &config, - /*auth*/ None, - "builtin-memories-test".to_string(), - McpRuntimeEnvironment::new( - Arc::new(codex_exec_server::Environment::default_for_tests()), - codex_home.path().to_path_buf(), - ), - McpSnapshotDetail::ToolsAndAuthOnly, - ) - .await; - - let tools = snapshot - .tools_by_server - .get(codex_builtin_mcps::MEMORIES_MCP_SERVER_NAME) - .expect("memories tools should be listed"); - assert_eq!( - tools - .keys() - .cloned() - .collect::>(), - ["list".to_string(), "read".to_string(), "search".to_string()] - .into_iter() - .collect() - ); -} diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index b67146ab6e..950bb225d9 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -10,14 +10,12 @@ use std::borrow::Cow; use std::collections::HashMap; use std::env; use std::ffi::OsString; -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 crate::builtin::BuiltinMcpServerFactory; use crate::codex_apps::CachedCodexAppsToolsLoad; use crate::codex_apps::CodexAppsToolsCacheContext; use crate::codex_apps::filter_disallowed_codex_apps_tools; @@ -51,7 +49,6 @@ use codex_exec_server::HttpClient; use codex_exec_server::ReqwestHttpClient; use codex_protocol::protocol::Event; use codex_rmcp_client::ExecutorStdioServerLauncher; -use codex_rmcp_client::InProcessTransportFactory; use codex_rmcp_client::LocalStdioServerLauncher; use codex_rmcp_client::RmcpClient; use codex_rmcp_client::StdioServerLauncher; @@ -147,9 +144,9 @@ impl AsyncManagedClient { codex_apps_tools_cache_context: Option, tool_plugin_provenance: Arc, runtime_environment: McpRuntimeEnvironment, - codex_home: PathBuf, runtime_auth_provider: Option, provenance: McpServerProvenance, + client_elicitation_capability: ElicitationCapability, ) -> Self { let tool_filter = server .configured_config() @@ -177,7 +174,6 @@ impl AsyncManagedClient { server.clone(), store_mode, runtime_environment, - codex_home, runtime_auth_provider, ) .await?, @@ -199,6 +195,7 @@ impl AsyncManagedClient { elicitation_requests, codex_apps_tools_cache_context, provenance, + client_elicitation_capability, }, ) .await @@ -337,14 +334,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, server_provenance: McpServerProvenance, @@ -402,6 +391,8 @@ pub(crate) async fn list_tools_for_client_uncached( ToolInfo { server_name: server_name.to_owned(), server_provenance, + supports_parallel_tool_calls: false, + server_origin: None, callable_name, callable_namespace, namespace_description, @@ -494,8 +485,8 @@ async fn start_server_task( elicitation_requests, codex_apps_tools_cache_context, provenance, + client_elicitation_capability, } = params; - let elicitation = elicitation_capability_for_server(&server_name); let initialize_params = InitializeRequestParams { meta: None, capabilities: ClientCapabilities { @@ -503,7 +494,7 @@ async fn start_server_task( extensions: None, roots: None, sampling: None, - elicitation, + elicitation: Some(client_elicitation_capability), tasks: None, }, client_info: Implementation { @@ -581,6 +572,7 @@ struct StartServerTaskParams { elicitation_requests: ElicitationRequestManager, codex_apps_tools_cache_context: Option, provenance: McpServerProvenance, + client_elicitation_capability: ElicitationCapability, } async fn make_rmcp_client( @@ -588,18 +580,10 @@ async fn make_rmcp_client( server: EffectiveMcpServer, store_mode: OAuthCredentialsStoreMode, runtime_environment: McpRuntimeEnvironment, - codex_home: PathBuf, runtime_auth_provider: Option, ) -> Result { let config = match server.launch() { McpServerLaunch::Configured(config) => config.as_ref().clone(), - McpServerLaunch::Builtin(builtin_server) => { - let factory: Arc = - Arc::new(BuiltinMcpServerFactory::new(*builtin_server, codex_home)); - return RmcpClient::new_in_process_client(factory) - .await - .map_err(|err| StartupOutcomeError::from(anyhow!(err))); - } }; let McpServerConfig { transport, diff --git a/codex-rs/codex-mcp/src/server.rs b/codex-rs/codex-mcp/src/server.rs index a57aceccb9..d8fb5a11f2 100644 --- a/codex-rs/codex-mcp/src/server.rs +++ b/codex-rs/codex-mcp/src/server.rs @@ -1,4 +1,3 @@ -use codex_builtin_mcps::BuiltinMcpServer; use codex_config::McpServerConfig; use codex_config::McpServerTransportConfig; @@ -6,10 +5,9 @@ use codex_config::McpServerTransportConfig; #[derive(Debug, Clone)] pub(crate) enum McpServerLaunch { Configured(Box), - Builtin(BuiltinMcpServer), } -/// MCP server after product-owned runtime additions have been applied. +/// MCP server after runtime additions have been applied. #[derive(Debug, Clone)] pub struct EffectiveMcpServer { launch: McpServerLaunch, @@ -22,12 +20,6 @@ impl EffectiveMcpServer { } } - pub fn builtin(server: BuiltinMcpServer) -> Self { - Self { - launch: McpServerLaunch::Builtin(server), - } - } - pub(crate) fn launch(&self) -> &McpServerLaunch { &self.launch } @@ -35,21 +27,18 @@ impl EffectiveMcpServer { pub fn configured_config(&self) -> Option<&McpServerConfig> { match &self.launch { McpServerLaunch::Configured(config) => Some(config.as_ref()), - McpServerLaunch::Builtin(_) => None, } } pub fn enabled(&self) -> bool { match &self.launch { McpServerLaunch::Configured(config) => config.enabled, - McpServerLaunch::Builtin(_) => true, } } pub fn required(&self) -> bool { match &self.launch { McpServerLaunch::Configured(config) => config.required, - McpServerLaunch::Builtin(_) => false, } } } @@ -57,7 +46,6 @@ impl EffectiveMcpServer { /// Transport origin retained for metrics and diagnostics after server launch. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum McpServerOrigin { - InProcess, Stdio, StreamableHttp(String), } @@ -65,7 +53,6 @@ pub(crate) enum McpServerOrigin { impl McpServerOrigin { pub fn as_str(&self) -> &str { match self { - Self::InProcess => "in_process", Self::Stdio => "stdio", Self::StreamableHttp(origin) => origin, } @@ -98,11 +85,6 @@ impl From<&EffectiveMcpServer> for McpServerMetadata { origin: McpServerOrigin::from_transport(&config.transport), supports_parallel_tool_calls: config.supports_parallel_tool_calls, }, - McpServerLaunch::Builtin(server) => Self { - pollutes_memory: server.pollutes_memory(), - origin: Some(McpServerOrigin::InProcess), - supports_parallel_tool_calls: server.supports_parallel_tool_calls(), - }, } } } diff --git a/codex-rs/codex-mcp/src/tools.rs b/codex-rs/codex-mcp/src/tools.rs index e71fa42bce..9ce36b1f4d 100644 --- a/codex-rs/codex-mcp/src/tools.rs +++ b/codex-rs/codex-mcp/src/tools.rs @@ -33,6 +33,12 @@ pub struct ToolInfo { /// Runtime-only provenance for the server that exposed this tool. #[serde(skip)] pub server_provenance: McpServerProvenance, + /// 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, 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..8bf3148ba8 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 { @@ -597,9 +598,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 +645,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 +666,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; + } + } } } @@ -769,7 +815,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)); } @@ -1583,6 +1629,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 +1674,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 +1724,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 +1737,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 +1750,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 +1763,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 +1775,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 d2eeaf927f..989aab1691 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; @@ -307,8 +306,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, @@ -345,14 +345,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>, @@ -488,9 +489,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 630d18c569..869761bdaa 100644 --- a/codex-rs/config/src/hook_config.rs +++ b/codex-rs/config/src/hook_config.rs @@ -126,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 69fcd3fe95..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()), @@ -110,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, @@ -154,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, @@ -164,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 3c5228609e..6e7e108978 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..e9f819bcf9 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 { diff --git a/codex-rs/config/src/profile_toml.rs b/codex-rs/config/src/profile_toml.rs index fab78a128c..420eb46147 100644 --- a/codex-rs/config/src/profile_toml.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -12,7 +12,6 @@ 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; @@ -24,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, 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/tui_keymap.rs b/codex-rs/config/src/tui_keymap.rs index 3e8be83d6a..90d82da933 100644 --- a/codex-rs/config/src/tui_keymap.rs +++ b/codex-rs/config/src/tui_keymap.rs @@ -494,6 +494,7 @@ fn normalize_key_name(key: &str, original: &str) -> Result { | "page-up" | "page-down" | "space" + | "minus" ) { return Ok(alias.to_string()); } @@ -508,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." )) } @@ -516,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() { @@ -581,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 2bc23be4ef..5d252c2b44 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -515,6 +515,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. @@ -525,6 +531,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 { @@ -535,6 +543,8 @@ impl Default for OtelConfig { exporter: OtelExporterKind::None, trace_exporter: OtelExporterKind::None, metrics_exporter: OtelExporterKind::Statsig, + span_attributes: BTreeMap::new(), + tracestate: BTreeMap::new(), } } } @@ -590,6 +600,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 { @@ -686,6 +706,18 @@ 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, 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 f9bdc9b56b..04ebaf8e7e 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -22,6 +22,7 @@ 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; @@ -44,8 +45,8 @@ 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/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index adb8084cdc..42f5ac73db 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -1196,19 +1196,37 @@ 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; + 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()) + { + 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, source: plugin.source, policy: plugin.policy, - interface: plugin.interface, keywords: plugin.keywords, + interface, }) }) .collect::>(); diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 1061c2c427..2e6854a2fb 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -2043,6 +2043,116 @@ enabled = false ); } +#[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(), + 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(); diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index b2da554fd5..bb0e13a79d 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -32,9 +32,12 @@ 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; @@ -84,6 +87,7 @@ 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, @@ -98,16 +102,16 @@ pub struct RemotePluginSummary { #[derive(Debug, Clone, PartialEq, Eq)] pub struct RemotePluginShareContext { pub remote_plugin_id: String, + pub discoverability: RemotePluginShareDiscoverability, pub share_url: Option, pub creator_account_user_id: Option, pub creator_name: Option, - pub share_targets: Option>, + pub share_principals: Option>, } #[derive(Debug, Clone, PartialEq)] pub struct RemotePluginShareSummary { pub summary: RemotePluginSummary, - pub share_url: Option, pub local_plugin_path: Option, } @@ -360,6 +364,8 @@ struct RemotePluginDirectoryItem { name: String, scope: RemotePluginScope, #[serde(default)] + discoverability: Option, + #[serde(default)] creator_account_user_id: Option, #[serde(default)] creator_name: Option, @@ -374,12 +380,37 @@ 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 + | RemotePluginShareDiscoverability::Private => { + Ok(REMOTE_SHARED_WITH_ME_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, - #[serde(default)] - role: Option, + role: RemotePluginSharePrincipalRole, name: String, } @@ -443,7 +474,7 @@ pub async fn fetch_remote_marketplaces( directory_plugins, installed_plugins, /*include_installed_only*/ true, - ) + )? } RemoteMarketplaceSource::WorkspaceDirectory => { let scope = RemotePluginScope::Workspace; @@ -455,7 +486,7 @@ pub async fn fetch_remote_marketplaces( directory_plugins, workspace_installed_plugins.clone().unwrap_or_default(), /*include_installed_only*/ false, - ) + )? } RemoteMarketplaceSource::SharedWithMe => build_remote_marketplace( REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME, @@ -463,7 +494,7 @@ pub async fn fetch_remote_marketplaces( fetch_shared_workspace_plugins(config, auth).await?, workspace_installed_plugins.clone().unwrap_or_default(), /*include_installed_only*/ false, - ), + )?, }; if let Some(marketplace) = marketplace { marketplaces.push(marketplace); @@ -479,7 +510,7 @@ fn build_remote_marketplace( directory_plugins: Vec, installed_plugins: Vec, include_installed_only: bool, -) -> Option { +) -> Result, RemotePluginCatalogError> { let directory_plugins = directory_plugins .into_iter() .map(|plugin| (plugin.id.clone(), plugin)) @@ -499,7 +530,7 @@ fn build_remote_marketplace( .cloned() .collect::>(); if plugin_ids.is_empty() { - return None; + return Ok(None); } let mut plugins = plugin_ids @@ -509,9 +540,10 @@ fn build_remote_marketplace( let installed_plugin = installed_plugins.get(&plugin_id); directory_plugin .or_else(|| installed_plugin.map(|plugin| &plugin.plugin)) - .map(|plugin| build_remote_plugin_summary(plugin, installed_plugin)) + .map(|plugin| (plugin, installed_plugin)) }) - .collect::>(); + .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() @@ -519,11 +551,11 @@ fn build_remote_marketplace( .then_with(|| remote_plugin_display_name(left).cmp(remote_plugin_display_name(right))) .then_with(|| left.id.cmp(&right.id)) }); - Some(RemoteMarketplace { + Ok(Some(RemoteMarketplace { name: name.to_string(), display_name: display_name.to_string(), plugins, - }) + })) } pub async fn fetch_remote_installed_plugins( @@ -545,12 +577,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) @@ -575,6 +604,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>, @@ -637,7 +679,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. @@ -686,7 +728,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, @@ -738,7 +780,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('/'); @@ -822,11 +864,20 @@ 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), + 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, @@ -834,48 +885,51 @@ fn build_remote_plugin_summary( availability: plugin.availability, interface: remote_plugin_interface_to_info(plugin), keywords: plugin.release.keywords.clone(), - } + }) } fn remote_plugin_share_context( plugin: &RemotePluginDirectoryItem, -) -> Option { +) -> Result, RemotePluginCatalogError> { match plugin.scope { - RemotePluginScope::Global => None, - RemotePluginScope::Workspace => Some(RemotePluginShareContext { - remote_plugin_id: plugin.id.clone(), - share_url: plugin.share_url.clone(), - creator_account_user_id: plugin.creator_account_user_id.clone(), - creator_name: plugin.creator_name.clone(), - share_targets: plugin.share_principals.as_ref().map(|principals| { - principals - .iter() - .filter(|principal| principal.role.as_deref() == Some("reader")) - .map(|principal| RemotePluginSharePrincipal { - principal_type: principal.principal_type, - principal_id: principal.principal_id.clone(), - name: principal.name.clone(), - }) - .collect() - }), - }), + RemotePluginScope::Global => Ok(None), + RemotePluginScope::Workspace => { + let discoverability = workspace_plugin_discoverability(plugin)?; + Ok(Some(RemotePluginShareContext { + remote_plugin_id: plugin.id.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 { 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..26cdce343a 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,12 @@ use super::REMOTE_GLOBAL_MARKETPLACE_NAME; +use super::REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME; use super::REMOTE_WORKSPACE_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 +152,18 @@ pub async fn sync_remote_installed_plugin_bundles_once( REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(), BTreeSet::new(), ), + ( + REMOTE_SHARED_WITH_ME_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 +298,7 @@ fn remove_stale_remote_plugin_caches( for marketplace_name in [ REMOTE_GLOBAL_MARKETPLACE_NAME, REMOTE_WORKSPACE_MARKETPLACE_NAME, + REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME, ] { let marketplace_root = codex_home.join(PLUGINS_CACHE_DIR).join(marketplace_name); if !marketplace_root.exists() { @@ -449,6 +456,10 @@ mod tests { REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(), BTreeSet::new(), ), + ( + REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), ]); let guard = mark_remote_plugin_cache_mutation_in_flight( @@ -487,4 +498,42 @@ mod tests { assert_eq!(removed, vec!["linear@chatgpt-global".to_string()]); assert!(!cached_manifest.exists()); } + + #[test] + fn stale_remote_plugin_cleanup_removes_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_SHARED_WITH_ME_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_SHARED_WITH_ME_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), + ]); + + let removed = remove_stale_remote_plugin_caches( + codex_home.path(), + &installed_plugin_names_by_marketplace, + ) + .expect("cleanup shared-with-me cache"); + + assert_eq!(removed, vec!["private-plugin@shared-with-me".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 22751a7ad2..6afeab74b1 100644 --- a/codex-rs/core-plugins/src/remote/share.rs +++ b/codex-rs/core-plugins/src/remote/share.rs @@ -32,7 +32,7 @@ pub struct RemotePluginShareAccessPolicy { pub share_targets: Option>, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] pub enum RemotePluginShareDiscoverability { Listed, @@ -40,6 +40,13 @@ pub enum RemotePluginShareDiscoverability { 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 { @@ -52,18 +59,36 @@ pub enum RemotePluginSharePrincipalType { 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)] @@ -100,12 +125,14 @@ struct RemoteWorkspacePluginCreateResponse { #[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( @@ -137,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, @@ -145,7 +175,7 @@ pub async fn save_remote_plugin_share( file_id: upload.file_id, etag, discoverability: access_policy.discoverability, - share_targets: access_policy.share_targets, + share_targets, }, ) .await?; @@ -190,33 +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)?; - Ok(local_paths + local_paths .into_iter() - .map(|(remote_plugin_id, local_plugin_path)| (local_plugin_path, remote_plugin_id)) - .collect()) + .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( @@ -245,19 +296,63 @@ pub async fn update_remote_plugin_share_targets( 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}/public/plugins/{remote_plugin_id}/shares"); + 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 { targets }); + 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 84e698f9dc..8b27b28c3f 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -93,6 +93,7 @@ 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": { @@ -116,6 +117,7 @@ fn remote_plugin_json_with_share_url_and_principals( 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 @@ -204,15 +206,17 @@ async fn save_remote_plugin_share_creates_workspace_plugin() { .and(body_json(json!({ "file_id": "file_123", "etag": "\"upload_etag_123\"", - "discoverability": "PRIVATE", + "discoverability": "UNLISTED", "share_targets": [ { "principal_type": "user", "principal_id": "user-1", + "role": "reader", }, { "principal_type": "workspace", - "principal_id": "workspace-1", + "principal_id": "account_id", + "role": "reader", }, ], }))) @@ -231,17 +235,12 @@ async fn save_remote_plugin_share_creates_workspace_plugin() { &plugin_path, /*remote_plugin_id*/ None, RemotePluginShareAccessPolicy { - discoverability: Some(RemotePluginShareDiscoverability::Private), - share_targets: Some(vec![ - RemotePluginShareTarget { - principal_type: RemotePluginSharePrincipalType::User, - principal_id: "user-1".to_string(), - }, - RemotePluginShareTarget { - principal_type: RemotePluginSharePrincipalType::Workspace, - principal_id: "workspace-1".to_string(), - }, - ]), + discoverability: Some(RemotePluginShareDiscoverability::Unlisted), + share_targets: Some(vec![RemotePluginShareTarget { + principal_type: RemotePluginSharePrincipalType::User, + principal_id: "user-1".to_string(), + role: RemotePluginShareTargetRole::Reader, + }]), }, ) .await @@ -401,18 +400,26 @@ async fn update_remote_plugin_share_targets_updates_targets() { let auth = test_auth(); Mock::given(method("PUT")) - .and(path("/backend-api/public/plugins/plugins_123/shares")) + .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", }, ], }))) @@ -421,14 +428,17 @@ async fn update_remote_plugin_share_targets_updates_targets() { { "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) @@ -442,12 +452,15 @@ async fn update_remote_plugin_share_targets_updates_targets() { 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(); @@ -459,14 +472,17 @@ async fn update_remote_plugin_share_targets_updates_targets() { 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, } ); } @@ -542,11 +558,6 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { "role": "editor", "name": "Editor", }, - { - "principal_type": "user", - "principal_id": "user-missing-role", - "name": "Missing Role", - }, ]), )], "pagination": empty_pagination_json(), @@ -574,20 +585,31 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { vec![ RemotePluginShareSummary { summary: RemotePluginSummary { - id: "plugins_123".to_string(), + id: "demo-plugin@shared-with-me".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(), + 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_targets: Some(vec![RemotePluginSharePrincipal { - principal_type: RemotePluginSharePrincipalType::User, - principal_id: "user-reader".to_string(), - name: "Reader".to_string(), - }]), + 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, @@ -597,19 +619,33 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { 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@shared-with-me".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(), + discoverability: RemotePluginShareDiscoverability::Private, share_url: None, creator_account_user_id: None, creator_name: None, - share_targets: Some(Vec::new()), + 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, @@ -619,7 +655,6 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { interface: Some(expected_plugin_interface()), keywords: Vec::new(), }, - share_url: None, local_plugin_path: None, } ] diff --git a/codex-rs/core-skills/src/manager.rs b/codex-rs/core-skills/src/manager.rs index 73b1f14807..db19322acf 100644 --- a/codex-rs/core-skills/src/manager.rs +++ b/codex-rs/core-skills/src/manager.rs @@ -131,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 @@ -161,18 +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), - plugin_id: None, - }), - ); - } 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 { @@ -300,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 532d6951ec..338da0c398 100644 --- a/codex-rs/core-skills/src/manager_tests.rs +++ b/codex-rs/core-skills/src/manager_tests.rs @@ -11,7 +11,6 @@ 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; @@ -263,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( @@ -366,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; @@ -386,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( @@ -433,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!( @@ -453,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] @@ -499,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(), @@ -522,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; @@ -555,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; @@ -576,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/BUILD.bazel b/codex-rs/core/BUILD.bazel index c78750576b..e826027cba 100644 --- a/codex-rs/core/BUILD.bazel +++ b/codex-rs/core/BUILD.bazel @@ -57,5 +57,7 @@ codex_rust_crate( "//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..86e7c07ad9 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 } @@ -85,7 +86,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/config.schema.json b/codex-rs/core/config.schema.json index 6eab1d4c55..5af6b7435b 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -379,9 +379,6 @@ "browser_use_external": { "type": "boolean" }, - "builtin_mcp": { - "type": "boolean" - }, "child_agents_md": { "type": "boolean" }, @@ -481,12 +478,18 @@ "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" }, @@ -670,12 +673,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" @@ -806,6 +805,16 @@ } ] }, + "FeatureToml_for_NetworkProxyConfigToml": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/NetworkProxyConfigToml" + } + ] + }, "FeedbackConfigToml": { "additionalProperties": false, "properties": { @@ -960,6 +969,10 @@ "command": { "type": "string" }, + "commandWindows": { + "default": null, + "type": "string" + }, "statusMessage": { "default": null, "type": "string" @@ -1498,6 +1511,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": { @@ -1695,6 +1777,13 @@ ], "description": "Optional metrics exporter" }, + "span_attributes": { + "additionalProperties": { + "type": "string" + }, + "description": "Attributes to add to every exported trace span.", + "type": "object" + }, "trace_exporter": { "allOf": [ { @@ -1702,6 +1791,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" @@ -2204,13 +2303,6 @@ }, "type": "object" }, - "ServiceTier": { - "enum": [ - "fast", - "flex" - ], - "type": "string" - }, "SessionPickerViewMode": { "description": "Preferred layout for the resume/fork session picker.", "enum": [ @@ -2338,24 +2430,6 @@ "type" ], "type": "object" - }, - { - "properties": { - "endpoint": { - "type": "string" - }, - "type": { - "enum": [ - "remote" - ], - "type": "string" - } - }, - "required": [ - "endpoint", - "type" - ], - "type": "object" } ] }, @@ -2618,6 +2692,20 @@ "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`.", @@ -3362,6 +3450,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.", @@ -3915,10 +4021,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" }, @@ -3951,9 +4053,6 @@ "browser_use_external": { "type": "boolean" }, - "builtin_mcp": { - "type": "boolean" - }, "child_agents_md": { "type": "boolean" }, @@ -4053,12 +4152,18 @@ "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" }, @@ -4507,12 +4612,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 f39c9a1826..079ee61f01 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -717,11 +717,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/apply_patch.rs b/codex-rs/core/src/apply_patch.rs index 2463d69c2b..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 { 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 39e6e85e20..545d131c69 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -105,6 +105,9 @@ 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; @@ -139,6 +142,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"; @@ -170,6 +175,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, } @@ -314,6 +321,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 @@ -322,6 +330,7 @@ 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 { session_id, @@ -335,6 +344,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()), }), @@ -463,9 +474,6 @@ impl ModelClient { text, .. } = request; - let client = - ApiCompactClient::new(transport, client_setup.api_provider, client_setup.api_auth) - .with_telemetry(Some(request_telemetry)); let payload = ApiCompactionInput { model: &model, input: &input, @@ -492,6 +500,12 @@ impl ModelClient { 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) @@ -505,11 +519,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(), @@ -640,6 +657,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, @@ -711,6 +742,8 @@ impl ModelClient { 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(), @@ -777,7 +810,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, @@ -854,7 +889,7 @@ 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>, @@ -872,6 +907,9 @@ impl ModelClient { } 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), @@ -920,7 +958,7 @@ 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, @@ -939,6 +977,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, @@ -1215,7 +1256,9 @@ 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.client.build_responses_request( &client_setup.api_provider, @@ -1226,6 +1269,7 @@ impl ModelClientSession { 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, @@ -1322,7 +1366,9 @@ impl ModelClientSession { ); let compression = self.responses_request_compression(client_setup.auth.as_ref()); - let options = self.build_responses_options(turn_metadata_header, compression); + let options = self + .build_responses_options(turn_metadata_header, compression) + .await; let request = self.client.build_responses_request( &client_setup.api_provider, prompt, @@ -1378,7 +1424,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 @@ -1387,6 +1433,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(|| { @@ -1595,6 +1642,23 @@ fn parse_turn_metadata_header(turn_metadata_header: Option<&str>) -> Option ModelClient { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*attestation_provider*/ None, ) } @@ -466,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 a89d8fc973..5a01384bf0 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -83,7 +83,7 @@ pub(crate) async fn run_codex_thread_interactive( 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), @@ -99,6 +99,7 @@ pub(crate) async fn run_codex_thread_interactive( 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??; 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 9fe235640a..c110d30f66 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -1,12 +1,12 @@ 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; @@ -31,6 +31,7 @@ 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; @@ -101,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 @@ -112,7 +112,6 @@ impl CodexThread { session_configured: SessionConfiguredEvent, rollout_path: Option, session_source: SessionSource, - watch_registration: WatchRegistration, ) -> Self { Self { codex, @@ -120,7 +119,6 @@ impl CodexThread { session_configured, rollout_path, out_of_band_elicitation_count: Mutex::new(0), - _watch_registration: watch_registration, } } @@ -128,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 } @@ -471,6 +474,10 @@ impl CodexThread { 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_remote.rs b/codex-rs/core/src/compact_remote.rs index eb9e454677..cc31d50b13 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -22,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; @@ -197,7 +198,11 @@ async fn run_remote_compact_task_inner_impl( CompactConversationRequestSettings { effort: turn_context.reasoning_effort, summary: turn_context.reasoning_summary, - service_tier: turn_context.config.service_tier.clone(), + 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, @@ -282,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, diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 7705616d37..7f6ea61a5b 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -19,6 +19,7 @@ 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; @@ -185,29 +186,29 @@ async fn run_remote_compact_task_inner_impl( "parallel_tool_calls": prompt.parallel_tool_calls, })); - let compaction_output_result = if let Some(client_session) = client_session { - run_remote_compaction_request_v2( - sess, - turn_context, - client_session, - &prompt, - turn_metadata_header.as_deref(), - ) - .await - } else { - let mut owned_client_session = sess.services.model_client.new_session(); - run_remote_compaction_request_v2( - sess, - turn_context, - &mut owned_client_session, - &prompt, - turn_metadata_header.as_deref(), - ) - .await + 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(std::slice::from_ref)); - let compaction_output = compaction_output_result?; + 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(), @@ -235,6 +236,12 @@ async fn run_remote_compact_task_inner_impl( 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(()) } @@ -244,7 +251,7 @@ async fn run_remote_compaction_request_v2( client_session: &mut ModelClientSession, prompt: &Prompt, turn_metadata_header: Option<&str>, -) -> CodexResult { +) -> CodexResult<(ResponseItem, String)> { let stream = client_session .stream( prompt, @@ -274,11 +281,11 @@ async fn run_remote_compaction_request_v2( async fn collect_context_compaction_output( mut stream: ResponseStream, -) -> CodexResult { +) -> CodexResult<(ResponseItem, String)> { let mut output_item_count = 0usize; let mut context_compaction_count = 0usize; let mut context_compaction_output = None; - let mut completed = false; + let mut completed_response_id = None; while let Some(event) = stream.next().await { match event? { ResponseEvent::OutputItemDone(item) => { @@ -303,19 +310,19 @@ async fn collect_context_compaction_output( _ => {} } } - ResponseEvent::Completed { .. } => { - completed = true; + ResponseEvent::Completed { response_id, .. } => { + completed_response_id = Some(response_id); break; } _ => {} } } - if !completed { + 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!( @@ -326,7 +333,7 @@ async fn collect_context_compaction_output( let Some(context_compaction_output) = context_compaction_output else { unreachable!("context compaction output must exist when count is exactly one"); }; - Ok(context_compaction_output) + Ok((context_compaction_output, response_id)) } fn build_v2_compacted_history( @@ -439,10 +446,11 @@ mod tests { }), ]); - let output = collect_context_compaction_output(stream) + 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..6296d13886 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -1157,6 +1157,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()), @@ -1752,6 +1753,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 +1805,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 7f65f704b1..05ea84c308 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -44,6 +44,9 @@ 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; @@ -52,6 +55,7 @@ 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; @@ -86,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; @@ -562,6 +569,8 @@ fn config_toml_deserializes_model_availability_nux() { 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 { @@ -788,59 +797,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()), @@ -873,6 +835,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 @@ -881,11 +1064,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(()) } @@ -2176,6 +2535,19 @@ session_picker_view = "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#" @@ -2188,6 +2560,56 @@ fn tui_session_picker_view_defaults_to_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#" @@ -2211,6 +2633,8 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { 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(), @@ -2277,6 +2701,27 @@ 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::( @@ -3348,69 +3793,6 @@ async fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result< Ok(()) } -#[tokio::test] -async fn to_mcp_config_empty_mcp_requirements_preserve_builtin_mcps() -> anyhow::Result<()> { - let codex_home = TempDir::new()?; - let requirements = codex_config::ConfigRequirementsToml { - mcp_servers: Some(BTreeMap::new()), - ..Default::default() - }; - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) - .build() - .await?; - let _ = config.features.enable(Feature::BuiltInMcp); - let _ = config.features.enable(Feature::MemoryTool); - 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.builtin_mcp_servers, - vec![codex_mcp::BuiltinMcpServer::Memories] - ); - - Ok(()) -} - -#[tokio::test] -async fn to_mcp_config_nonempty_mcp_requirements_preserve_builtin_mcps() -> anyhow::Result<()> { - let codex_home = TempDir::new()?; - let requirements = codex_config::ConfigRequirementsToml { - mcp_servers: Some(BTreeMap::from([( - "docs".to_string(), - McpServerRequirement { - identity: McpServerIdentity::Command { - command: "docs-mcp".to_string(), - }, - }, - )])), - ..Default::default() - }; - let mut config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .cloud_requirements(CloudRequirementsLoader::new(async move { - Ok(Some(requirements)) - })) - .build() - .await?; - let _ = config.features.enable(Feature::BuiltInMcp); - let _ = config.features.enable(Feature::MemoryTool); - 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.builtin_mcp_servers, - vec![codex_mcp::BuiltinMcpServer::Memories] - ); - - Ok(()) -} - #[tokio::test] async fn sqlite_home_defaults_to_codex_home_for_workspace_write() -> std::io::Result<()> { let codex_home = TempDir::new()?; @@ -4235,7 +4617,7 @@ async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<( } #[tokio::test] -async fn to_mcp_config_includes_enabled_builtin_mcps() -> std::io::Result<()> { +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(), @@ -4243,73 +4625,22 @@ async fn to_mcp_config_includes_enabled_builtin_mcps() -> std::io::Result<()> { codex_home.abs(), ) .await?; - let _ = config.features.enable(Feature::BuiltInMcp); - let _ = config.features.enable(Feature::MemoryTool); 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.builtin_mcp_servers, - vec![codex_mcp::BuiltinMcpServer::Memories] - ); - assert!( - !mcp_config - .configured_mcp_servers - .contains_key(codex_mcp::MEMORIES_MCP_SERVER_NAME) + mcp_config.client_elicitation_capability, + ElicitationCapability::default() ); - Ok(()) -} - -#[tokio::test] -async fn to_mcp_config_omits_builtin_mcps_when_feature_is_disabled() -> 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 _ = config.features.enable(Feature::MemoryTool); - let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); - + let _ = config.features.enable(Feature::AuthElicitation); let mcp_config = config.to_mcp_config(&plugins_manager).await; - - assert!(mcp_config.builtin_mcp_servers.is_empty()); - - Ok(()) -} - -#[tokio::test] -async fn to_mcp_config_reserves_enabled_builtin_mcp_names() -> std::io::Result<()> { - let codex_home = TempDir::new()?; - let mut config = Config::load_from_base_config_with_overrides( - ConfigToml { - mcp_servers: HashMap::from([( - codex_mcp::MEMORIES_MCP_SERVER_NAME.to_string(), - http_mcp("https://user.example/memories"), - )]), - ..ConfigToml::default() - }, - ConfigOverrides::default(), - codex_home.abs(), - ) - .await?; - let _ = config.features.enable(Feature::BuiltInMcp); - let _ = config.features.enable(Feature::MemoryTool); - 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.builtin_mcp_servers, - vec![codex_mcp::BuiltinMcpServer::Memories] - ); - assert!( - !mcp_config - .configured_mcp_servers - .contains_key(codex_mcp::MEMORIES_MCP_SERVER_NAME) + mcp_config.client_elicitation_capability, + ElicitationCapability { + form: Some(FormElicitationCapability::default()), + url: Some(UrlElicitationCapability::default()), + } ); Ok(()) @@ -7045,7 +7376,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { 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), @@ -7076,6 +7407,8 @@ 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(), }, @@ -7135,6 +7468,119 @@ async fn trace_exporter_defaults_to_none_when_log_exporter_is_set() -> std::io:: 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()?; @@ -7177,6 +7623,78 @@ async fn legacy_fast_service_tier_override_uses_priority_request_value() -> std: 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()?; @@ -7304,7 +7822,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { 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), @@ -7335,6 +7853,8 @@ 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(), }; @@ -7462,7 +7982,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { 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), @@ -7493,6 +8013,8 @@ 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(), }; @@ -7605,7 +8127,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { 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), @@ -7636,6 +8158,8 @@ 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(), }; diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 1362103364..4dde2c0cb0 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -33,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]`. @@ -92,6 +92,14 @@ 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 { @@ -536,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"], @@ -1114,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 } diff --git a/codex-rs/core/src/config/edit_tests.rs b/codex-rs/core/src/config/edit_tests.rs index 28e388dcdb..335bcd38e0 100644 --- a/codex-rs/core/src/config/edit_tests.rs +++ b/codex-rs/core/src/config/edit_tests.rs @@ -3,6 +3,7 @@ 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)] @@ -32,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"); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index b2dfbcb2b8..f5dda3fc38 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,15 +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; @@ -68,11 +65,10 @@ 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::BuiltinMcpServerOptions; use codex_mcp::McpConfig; -use codex_mcp::enabled_builtin_mcp_servers; use codex_memories_read::memory_root; use codex_model_provider_info::LEGACY_OLLAMA_CHAT_PROVIDER_ID; use codex_model_provider_info::ModelProviderInfo; @@ -104,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; @@ -115,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; @@ -132,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; @@ -383,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 }, } @@ -551,6 +550,12 @@ 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, @@ -712,7 +717,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. @@ -1091,11 +1096,6 @@ impl Config { ) -> McpConfig { let plugins_input = self.plugins_config_input(); let loaded_plugins = plugins_manager.plugins_for_config(&plugins_input).await; - let builtin_mcp_servers = enabled_builtin_mcp_servers(BuiltinMcpServerOptions { - memories_enabled: self.features.enabled(Feature::BuiltInMcp) - && self.features.enabled(Feature::MemoryTool) - && self.memories.use_memories, - }); let mut configured_mcp_servers = self.mcp_servers.get().clone(); for plugin in loaded_plugins .plugins() @@ -1116,13 +1116,9 @@ impl Config { && mcp_requirements.value.is_empty() { // A present empty allowlist bans configurable MCPs, including plugin MCPs merged - // above. Built-ins are product-owned and stay available regardless of admin - // allowlists. + // above. filter_mcp_servers_by_requirements(&mut configured_mcp_servers, Some(mcp_requirements)); } - for builtin_server in &builtin_mcp_servers { - configured_mcp_servers.remove(builtin_server.name()); - } McpConfig { chatgpt_base_url: self.chatgpt_base_url.clone(), @@ -1138,8 +1134,17 @@ 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, - builtin_mcp_servers, plugin_capability_summaries: loaded_plugins.capability_summaries().to_vec(), } } @@ -1736,17 +1741,11 @@ 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, } } @@ -2036,6 +2035,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, @@ -2100,6 +2106,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 @@ -2219,6 +2232,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); @@ -2302,7 +2316,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, @@ -2515,6 +2529,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(); @@ -2738,10 +2768,7 @@ impl Config { notices.fast_default_opt_out = Some(true); None } - None => config_profile - .service_tier - .or(cfg.service_tier) - .map(|service_tier| service_tier.request_value().to_string()), + None => config_profile.service_tier.or(cfg.service_tier), }; let service_tier = service_tier.and_then(|service_tier| { match ServiceTier::from_request_value(&service_tier) { @@ -2885,12 +2912,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", @@ -2978,6 +2999,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, @@ -3124,10 +3146,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, @@ -3193,6 +3212,12 @@ 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() @@ -3205,26 +3230,7 @@ impl Config { .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); - // 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 = t.trace_exporter.unwrap_or(OtelExporterKind::None); - 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 3557706fe9..95642214d2 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; @@ -49,7 +44,6 @@ 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 { @@ -120,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, @@ -202,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, @@ -281,6 +277,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_environment_manager( 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, @@ -436,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; @@ -454,67 +451,25 @@ 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 -} - -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}"); - } + codex_connectors::cached_directory_connectors(&cache_context).unwrap_or_default() } pub(crate) fn accessible_connectors_from_mcp_tools(mcp_tools: &[ToolInfo]) -> Vec { @@ -581,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, ) } @@ -639,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 @@ -666,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)); @@ -679,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 2194cf9b6f..eecd909725 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; @@ -20,6 +22,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; @@ -119,6 +122,8 @@ fn codex_app_tool( ToolInfo { server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), server_provenance: McpServerProvenance::HostOwnedCodexApps, + supports_parallel_tool_calls: false, + server_origin: None, callable_name: tool_name.to_string(), callable_namespace: tool_namespace, namespace_description: None, @@ -190,6 +195,8 @@ fn accessible_connectors_from_mcp_tools_carries_plugin_display_names() { ToolInfo { server_name: "sample".to_string(), server_provenance: Default::default(), + supports_parallel_tool_calls: false, + server_origin: None, callable_name: "echo".to_string(), callable_namespace: "sample".to_string(), namespace_description: None, @@ -306,6 +313,8 @@ fn accessible_connectors_from_mcp_tools_preserves_description() { let mcp_tools = vec![ToolInfo { server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), server_provenance: McpServerProvenance::HostOwnedCodexApps, + 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()), @@ -362,6 +371,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!( @@ -390,6 +400,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!( @@ -418,6 +429,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!( @@ -486,6 +498,7 @@ fn requirements_disabled_connector_overrides_enabled_connector() { "connector_123123".to_string(), AppRequirementToml { enabled: Some(false), + tools: None, }, )]), }; @@ -518,6 +531,7 @@ fn requirements_enabled_does_not_override_disabled_connector() { "connector_123123".to_string(), AppRequirementToml { enabled: Some(true), + tools: None, }, )]), }; @@ -551,6 +565,7 @@ enabled = true "connector_123123".to_string(), AppRequirementToml { enabled: Some(false), + tools: None, }, )]), }), @@ -594,6 +609,7 @@ async fn cloud_requirements_disable_connector_applies_without_user_apps_table() "connector_123123".to_string(), AppRequirementToml { enabled: Some(false), + tools: None, }, )]), }), @@ -644,6 +660,7 @@ async fn local_requirements_disable_connector_overrides_user_apps_config() { "connector_123123".to_string(), AppRequirementToml { enabled: Some(false), + tools: None, }, )]), }), @@ -695,6 +712,7 @@ async fn local_requirements_disable_connector_applies_without_user_apps_table() "connector_123123".to_string(), AppRequirementToml { enabled: Some(false), + tools: None, }, )]), }), @@ -736,6 +754,7 @@ async fn with_app_enabled_state_preserves_unrelated_disabled_connector() { "connector_drive".to_string(), AppRequirementToml { enabled: Some(false), + tools: None, }, )]), }), @@ -776,6 +795,7 @@ fn app_tool_policy_honors_default_app_enabled_false() { Some(&annotations( /*destructive_hint*/ None, /*open_world_hint*/ None, )), + /*managed_approval*/ None, ); assert_eq!( @@ -787,6 +807,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 { @@ -816,6 +1040,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!( @@ -858,6 +1083,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!( @@ -892,6 +1118,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!( @@ -928,6 +1155,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!( @@ -966,6 +1194,7 @@ fn app_tool_policy_uses_default_tools_approval_mode() { Some(&annotations( /*destructive_hint*/ None, /*open_world_hint*/ None, )), + /*managed_approval*/ None, ); assert_eq!( @@ -1008,6 +1237,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!( @@ -1124,6 +1354,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 ca1ac5f2fb..272e3c617d 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -96,6 +96,24 @@ 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 { @@ -288,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 bc0a17ca5d..68ff7c9d44 100644 --- a/codex-rs/core/src/context/environment_context_tests.rs +++ b/codex-rs/core/src/context/environment_context_tests.rs @@ -71,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() ); 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/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index b4bd9cbe89..89808c27ee 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -15,12 +15,12 @@ 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() } @@ -85,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; @@ -118,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"); 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/goals.rs b/codex-rs/core/src/goals.rs index 7de2737b32..4a7a9a3d0b 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -5,6 +5,8 @@ //! 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; @@ -68,6 +70,15 @@ static BUDGET_LIMIT_PROMPT_TEMPLATE: LazyLock