From 8bea5d231a1d3a14124cec8725c0c9a98d84eaca Mon Sep 17 00:00:00 2001 From: William Woodruff Date: Fri, 8 May 2026 10:19:27 -0700 Subject: [PATCH 01/27] [codex] Address some more GHA hygiene issues (#21622) This does two things: - We use `persist-credentials: false` everywhere now. This is unfortunately not the default in GitHub Actions, but it prevents `actions/checkout` from dropping `secrets.GITHUB_TOKEN` onto disk. - We interpose (some) template expansions through environment variables. I've limited this to contexts that have non-fixed values; contexts that are fixed (like `*.result`) are not dangerous to expand directly inline (but maybe we should clean those up in the future for consistency anyways). This is a medium-risk change in terms of CI breakage: I did a scan for usage of `git push` and other commands that implicitly use the persisted credential, but couldn't find any. Even still, some implicit usages of the persisted credentials may be lurking. Please ping ww@ if any issues arise. --- .github/workflows/bazel.yml | 8 +++++++ .github/workflows/blob-size-policy.yml | 1 + .github/workflows/cargo-deny.yml | 2 ++ .github/workflows/ci.yml | 2 ++ .github/workflows/codespell.yml | 2 ++ .github/workflows/issue-deduplicator.yml | 4 ++++ .github/workflows/issue-labeler.yml | 2 ++ .github/workflows/rust-ci-full.yml | 16 +++++++++++++- .github/workflows/rust-ci.yml | 22 +++++++++++++++---- .../rust-release-argument-comment-lint.yml | 2 ++ .github/workflows/rust-release-prepare.yml | 1 + .github/workflows/rust-release-windows.yml | 4 ++++ .github/workflows/rust-release-zsh.yml | 4 ++++ .github/workflows/rust-release.yml | 7 ++++++ .github/workflows/rusty-v8-release.yml | 4 ++++ .github/workflows/sdk.yml | 2 ++ .github/workflows/v8-canary.yml | 4 ++++ 17 files changed, 82 insertions(+), 5 deletions(-) diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 082434de0b..627993c811 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -57,6 +57,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + 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 +151,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Prepare Bazel CI id: prepare_bazel @@ -232,6 +236,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Prepare Bazel CI id: prepare_bazel @@ -319,6 +325,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + 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..e7817a9f65 100644 --- a/.github/workflows/blob-size-policy.yml +++ b/.github/workflows/blob-size-policy.yml @@ -11,6 +11,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - name: Determine PR comparison range id: range diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml index 024198b8d1..94967e7e79 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -15,6 +15,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + 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..3754155912 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + 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..d4a8469a53 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -19,6 +19,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + 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 5a5e98ccf7..8c883a6e2d 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -23,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 @@ -37,6 +39,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 - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 with: @@ -53,6 +57,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 @@ -103,6 +109,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 }} @@ -239,6 +247,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 @@ -565,6 +575,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 @@ -727,10 +739,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..243c208436 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -17,6 +17,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + persist-credentials: false - name: Detect changed paths (no external action) id: detect shell: bash @@ -62,6 +63,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 @@ -78,6 +81,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 - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 with: @@ -96,6 +101,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 - name: Install nightly argument-comment-lint toolchain shell: bash @@ -172,6 +179,8 @@ jobs: echo "run=false" >> "$GITHUB_OUTPUT" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 if: ${{ steps.argument_comment_lint_gate.outputs.run == 'true' }} + with: + 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 +212,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..2eb7ef2a47 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 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..d05340a020 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 @@ -477,6 +482,8 @@ jobs: 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 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..cc26a4785a 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -14,6 +14,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + 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..50ec353c2e 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -41,6 +41,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 @@ -75,6 +77,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Set up Bazel uses: ./.github/actions/setup-bazel-ci From 872b8b15b38acbcc19457ef96b171819a56206db Mon Sep 17 00:00:00 2001 From: David de Regt Date: Fri, 8 May 2026 10:54:01 -0700 Subject: [PATCH 02/27] feat: Use installation ID in remote enrollments (#21662) * Pass installation ID for storage on enrollments server for deduping/grouping multiple appservers per installation * Pass installation ID in remoteControl/status/changed events --- .../schema/json/ServerNotification.json | 6 +- .../codex_app_server_protocol.schemas.json | 6 +- .../codex_app_server_protocol.v2.schemas.json | 6 +- ...emoteControlStatusChangedNotification.json | 6 +- .../RemoteControlStatusChangedNotification.ts | 4 +- .../src/protocol/v2/remote_control.rs | 3 +- codex-rs/app-server-transport/src/lib.rs | 1 + .../app-server-transport/src/transport/mod.rs | 1 + .../src/transport/remote_control/enroll.rs | 5 ++ .../src/transport/remote_control/mod.rs | 17 +++- .../src/transport/remote_control/protocol.rs | 1 + .../src/transport/remote_control/tests.rs | 81 ++++++++++++++++--- .../src/transport/remote_control/websocket.rs | 55 +++++++++++-- codex-rs/app-server/src/lib.rs | 7 +- codex-rs/app-server/src/transport.rs | 1 + 15 files changed, 169 insertions(+), 31 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 4e9e63d302..6af19f89e2 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2737,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": [ @@ -2745,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/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 156f6ddc4a..1747274799 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 @@ -13306,7 +13306,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": [ @@ -13314,11 +13314,15 @@ "null" ] }, + "installationId": { + "type": "string" + }, "status": { "$ref": "#/definitions/v2/RemoteControlConnectionStatus" } }, "required": [ + "installationId", "status" ], "title": "RemoteControlStatusChangedNotification", 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 3c5eb030c5..05b9165610 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 @@ -9899,7 +9899,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": [ @@ -9907,11 +9907,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/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/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/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-transport/src/lib.rs b/codex-rs/app-server-transport/src/lib.rs index 0a5c080acc..e3f39f60ce 100644 --- a/codex-rs/app-server-transport/src/lib.rs +++ b/codex-rs/app-server-transport/src/lib.rs @@ -11,6 +11,7 @@ pub use transport::AppServerTransportParseError; pub use transport::CHANNEL_CAPACITY; pub use transport::ConnectionOrigin; pub use transport::RemoteControlHandle; +pub use transport::RemoteControlStartConfig; pub use transport::TransportEvent; pub use transport::app_server_control_socket_path; pub use transport::auth; diff --git a/codex-rs/app-server-transport/src/transport/mod.rs b/codex-rs/app-server-transport/src/transport/mod.rs index c63a79a0c1..3e76069f43 100644 --- a/codex-rs/app-server-transport/src/transport/mod.rs +++ b/codex-rs/app-server-transport/src/transport/mod.rs @@ -31,6 +31,7 @@ mod unix_socket_tests; mod websocket; pub use remote_control::RemoteControlHandle; +pub use remote_control::RemoteControlStartConfig; pub use remote_control::start_remote_control; pub use stdio::start_stdio_connection; pub use unix_socket::start_control_socket_acceptor; diff --git a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs index fb7f727b83..60dfc3d845 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs @@ -19,6 +19,7 @@ const REQUEST_ID_HEADER: &str = "x-request-id"; const OAI_REQUEST_ID_HEADER: &str = "x-oai-request-id"; const CF_RAY_HEADER: &str = "cf-ray"; pub(super) const REMOTE_CONTROL_ACCOUNT_ID_HEADER: &str = "chatgpt-account-id"; +pub(super) const REMOTE_CONTROL_INSTALLATION_ID_HEADER: &str = "x-codex-installation-id"; #[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct RemoteControlEnrollment { @@ -193,6 +194,7 @@ pub(crate) fn format_headers(headers: &HeaderMap) -> String { pub(super) async fn enroll_remote_control_server( remote_control_target: &RemoteControlTarget, auth: &RemoteControlConnectionAuth, + installation_id: &str, ) -> io::Result { let enroll_url = &remote_control_target.enroll_url; let server_name = gethostname().to_string_lossy().trim().to_string(); @@ -201,6 +203,7 @@ pub(super) async fn enroll_remote_control_server( os: std::env::consts::OS, arch: std::env::consts::ARCH, app_server_version: env!("CARGO_PKG_VERSION"), + installation_id: installation_id.to_string(), }; let client = build_reqwest_client(); let mut auth_headers = HeaderMap::new(); @@ -210,6 +213,7 @@ pub(super) async fn enroll_remote_control_server( .timeout(REMOTE_CONTROL_ENROLL_TIMEOUT) .headers(auth_headers) .header(REMOTE_CONTROL_ACCOUNT_ID_HEADER, &auth.account_id) + .header(REMOTE_CONTROL_INSTALLATION_ID_HEADER, installation_id) .json(&request); let response = http_request.send().await.map_err(|err| { @@ -459,6 +463,7 @@ mod tests { auth_provider: codex_model_provider::unauthenticated_auth_provider(), account_id: "account_id".to_string(), }, + "11111111-1111-4111-8111-111111111111", ) .await .expect_err("invalid response should fail to parse"); diff --git a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs index 87405efa4f..9ffe8b60bb 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs @@ -28,6 +28,11 @@ use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use tracing::warn; +pub struct RemoteControlStartConfig { + pub remote_control_url: String, + pub installation_id: String, +} + pub(super) struct QueuedServerEnvelope { pub(super) event: ServerEvent, pub(super) client_id: ClientId, @@ -62,7 +67,7 @@ impl RemoteControlHandle { } pub async fn start_remote_control( - remote_control_url: String, + config: RemoteControlStartConfig, state_db: Option>, auth_manager: Arc, transport_event_tx: mpsc::Sender, @@ -77,7 +82,7 @@ pub async fn start_remote_control( warn!("remote control disabled because sqlite state db is unavailable"); } let remote_control_target = if initial_enabled { - Some(normalize_remote_control_url(&remote_control_url)?) + Some(normalize_remote_control_url(&config.remote_control_url)?) } else { None }; @@ -89,14 +94,18 @@ pub async fn start_remote_control( } else { RemoteControlConnectionStatus::Disabled }, + installation_id: config.installation_id.clone(), environment_id: None, }; let (status_tx, _status_rx) = watch::channel(initial_status); let status_publisher = RemoteControlStatusPublisher::new(status_tx.clone()); let join_handle = tokio::spawn(async move { RemoteControlWebsocket::new( - remote_control_url, - remote_control_target, + websocket::RemoteControlWebsocketConfig { + remote_control_url: config.remote_control_url, + installation_id: config.installation_id, + remote_control_target, + }, state_db, auth_manager, RemoteControlChannels { diff --git a/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs b/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs index dea5404ab1..cee646b0d9 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/protocol.rs @@ -19,6 +19,7 @@ pub(super) struct EnrollRemoteServerRequest { pub(super) os: &'static str, pub(super) arch: &'static str, pub(super) app_server_version: &'static str, + pub(super) installation_id: String, } #[derive(Debug, Deserialize)] diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 5fd3caa401..88b7798827 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -1,4 +1,5 @@ use super::enroll::REMOTE_CONTROL_ACCOUNT_ID_HEADER; +use super::enroll::REMOTE_CONTROL_INSTALLATION_ID_HEADER; use super::enroll::RemoteControlEnrollment; use super::enroll::load_persisted_remote_control_enrollment; use super::enroll::update_persisted_remote_control_enrollment; @@ -56,6 +57,8 @@ use tokio_tungstenite::accept_hdr_async; use tokio_tungstenite::tungstenite; use tokio_util::sync::CancellationToken; +const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; + fn remote_control_auth_manager() -> Arc { auth_manager_from_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) } @@ -131,6 +134,7 @@ async fn expect_remote_control_status( if let Some(expected_status) = expected_status { assert_eq!(status.status, expected_status); } + assert_eq!(status.installation_id, TEST_INSTALLATION_ID); assert_eq!(status.environment_id.as_deref(), expected_environment_id); } @@ -173,7 +177,10 @@ async fn remote_control_transport_manages_virtual_clients_and_routes_messages() mpsc::channel::(CHANNEL_CAPACITY); let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( - remote_control_url, + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, Some(remote_control_state_runtime(&codex_home).await), remote_control_auth_manager(), transport_event_tx, @@ -449,7 +456,10 @@ async fn remote_control_transport_reconnects_after_disconnect() { mpsc::channel::(CHANNEL_CAPACITY); let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( - remote_control_url, + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, Some(remote_control_state_runtime(&codex_home).await), remote_control_auth_manager(), transport_event_tx, @@ -528,7 +538,10 @@ async fn remote_control_start_allows_remote_control_invalid_url_when_disabled() mpsc::channel::(CHANNEL_CAPACITY); let shutdown_token = CancellationToken::new(); let (remote_task, _remote_handle) = start_remote_control( - "https://internal.example.com/backend-api/".to_string(), + RemoteControlStartConfig { + remote_control_url: "https://internal.example.com/backend-api/".to_string(), + installation_id: TEST_INSTALLATION_ID.to_string(), + }, /*state_db*/ None, remote_control_auth_manager(), transport_event_tx, @@ -564,7 +577,10 @@ async fn remote_control_start_allows_missing_auth_when_enabled() { mpsc::channel::(CHANNEL_CAPACITY); let shutdown_token = CancellationToken::new(); let (remote_task, _remote_handle) = start_remote_control( - remote_control_url, + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, Some(remote_control_state_runtime(&codex_home).await), auth_manager, transport_event_tx, @@ -596,7 +612,10 @@ async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled( mpsc::channel::(CHANNEL_CAPACITY); let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( - remote_control_url, + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, /*state_db*/ None, remote_control_auth_manager(), transport_event_tx, @@ -611,6 +630,7 @@ async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled( status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Disabled, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } ); @@ -645,7 +665,10 @@ async fn remote_control_handle_set_enabled_stops_and_restarts_connections() { mpsc::channel::(CHANNEL_CAPACITY); let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( - remote_control_url, + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, Some(remote_control_state_runtime(&codex_home).await), remote_control_auth_manager(), transport_event_tx, @@ -672,6 +695,7 @@ async fn remote_control_handle_set_enabled_stops_and_restarts_connections() { &mut status_rx, RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connected, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_test".to_string()), }, ) @@ -682,6 +706,7 @@ async fn remote_control_handle_set_enabled_stops_and_restarts_connections() { &mut status_rx, RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Disabled, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, }, ) @@ -698,6 +723,7 @@ async fn remote_control_handle_set_enabled_stops_and_restarts_connections() { &mut status_rx, RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_test".to_string()), }, ) @@ -729,7 +755,10 @@ async fn remote_control_transport_clears_outgoing_buffer_when_backend_acks() { mpsc::channel::(CHANNEL_CAPACITY); let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( - remote_control_url, + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, Some(remote_control_state_runtime(&codex_home).await), remote_control_auth_manager(), transport_event_tx, @@ -904,7 +933,10 @@ async fn remote_control_http_mode_enrolls_before_connecting() { let expected_server_name = gethostname().to_string_lossy().trim().to_string(); let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( - remote_control_url, + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, Some(remote_control_state_runtime(&codex_home).await), remote_control_auth_manager(), transport_event_tx, @@ -929,6 +961,12 @@ async fn remote_control_http_mode_enrolls_before_connecting() { enroll_request.headers.get(REMOTE_CONTROL_ACCOUNT_ID_HEADER), Some(&"account_id".to_string()) ); + assert_eq!( + enroll_request + .headers + .get(REMOTE_CONTROL_INSTALLATION_ID_HEADER), + Some(&TEST_INSTALLATION_ID.to_string()) + ); assert_eq!( serde_json::from_str::(&enroll_request.body) .expect("enroll body should deserialize"), @@ -937,6 +975,7 @@ async fn remote_control_http_mode_enrolls_before_connecting() { "os": std::env::consts::OS, "arch": std::env::consts::ARCH, "app_server_version": env!("CARGO_PKG_VERSION"), + "installation_id": TEST_INSTALLATION_ID, }) ); respond_with_json( @@ -967,6 +1006,12 @@ async fn remote_control_http_mode_enrolls_before_connecting() { .get(REMOTE_CONTROL_ACCOUNT_ID_HEADER), Some(&"account_id".to_string()) ); + assert_eq!( + handshake_request + .headers + .get(REMOTE_CONTROL_INSTALLATION_ID_HEADER), + Some(&TEST_INSTALLATION_ID.to_string()) + ); assert_eq!( handshake_request.headers.get("x-codex-server-id"), Some(&"srv_e_test".to_string()) @@ -1128,7 +1173,10 @@ async fn remote_control_http_mode_reuses_persisted_enrollment_before_reenrolling mpsc::channel::(CHANNEL_CAPACITY); let shutdown_token = CancellationToken::new(); let (remote_task, _remote_handle) = start_remote_control( - remote_control_url, + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, Some(state_db.clone()), remote_control_auth_manager_with_home(&codex_home), transport_event_tx, @@ -1196,7 +1244,10 @@ async fn remote_control_stdio_mode_waits_for_client_name_before_connecting() { let (app_server_client_name_tx, app_server_client_name_rx) = oneshot::channel::(); let shutdown_token = CancellationToken::new(); let (remote_task, _remote_handle) = start_remote_control( - remote_control_url, + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, Some(state_db.clone()), remote_control_auth_manager_with_home(&codex_home), transport_event_tx, @@ -1255,7 +1306,10 @@ async fn remote_control_waits_for_account_id_before_enrolling() { mpsc::channel::(CHANNEL_CAPACITY); let shutdown_token = CancellationToken::new(); let (remote_task, _remote_handle) = start_remote_control( - remote_control_url, + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, Some(state_db.clone()), auth_manager, transport_event_tx, @@ -1338,7 +1392,10 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404() mpsc::channel::(CHANNEL_CAPACITY); let shutdown_token = CancellationToken::new(); let (remote_task, remote_handle) = start_remote_control( - remote_control_url, + RemoteControlStartConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + }, Some(state_db.clone()), remote_control_auth_manager_with_home(&codex_home), transport_event_tx, diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index f7b49b72ec..472639bc68 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -55,6 +55,7 @@ use tracing::warn; pub(super) const REMOTE_CONTROL_PROTOCOL_VERSION: &str = "3"; pub(super) const REMOTE_CONTROL_ACCOUNT_ID_HEADER: &str = "chatgpt-account-id"; +pub(super) const REMOTE_CONTROL_INSTALLATION_ID_HEADER: &str = "x-codex-installation-id"; const REMOTE_CONTROL_SUBSCRIBE_CURSOR_HEADER: &str = "x-codex-subscribe-cursor"; const REMOTE_CONTROL_WEBSOCKET_PING_INTERVAL: std::time::Duration = std::time::Duration::from_secs(10); @@ -214,6 +215,7 @@ impl WebsocketState { pub(crate) struct RemoteControlWebsocket { remote_control_url: String, + installation_id: String, remote_control_target: Option, state_db: Option>, auth_manager: Arc, @@ -229,6 +231,12 @@ pub(crate) struct RemoteControlWebsocket { enabled_rx: watch::Receiver, } +pub(crate) struct RemoteControlWebsocketConfig { + pub(crate) remote_control_url: String, + pub(crate) installation_id: String, + pub(crate) remote_control_target: Option, +} + enum ConnectOutcome { Connected(Box>>), Disabled, @@ -254,6 +262,7 @@ impl RemoteControlStatusPublisher { self.tx.send_if_modified(|status| { let next_status = RemoteControlStatusChangedNotification { status: connection_status, + installation_id: status.installation_id.clone(), environment_id: if connection_status == RemoteControlConnectionStatus::Disabled { None } else { @@ -276,6 +285,7 @@ impl RemoteControlStatusPublisher { } let next_status = RemoteControlStatusChangedNotification { status: status.status, + installation_id: status.installation_id.clone(), environment_id, }; if *status == next_status { @@ -290,14 +300,14 @@ impl RemoteControlStatusPublisher { #[derive(Clone, Copy)] pub(super) struct RemoteControlConnectOptions<'a> { + installation_id: &'a str, subscribe_cursor: Option<&'a str>, app_server_client_name: Option<&'a str>, } impl RemoteControlWebsocket { pub(crate) fn new( - remote_control_url: String, - remote_control_target: Option, + config: RemoteControlWebsocketConfig, state_db: Option>, auth_manager: Arc, channels: RemoteControlChannels, @@ -315,8 +325,9 @@ impl RemoteControlWebsocket { let auth_recovery = auth_manager.unauthorized_recovery(); Self { - remote_control_url, - remote_control_target, + remote_control_url: config.remote_control_url, + installation_id: config.installation_id, + remote_control_target: config.remote_control_target, state_db, auth_manager, status_publisher: channels.status_publisher, @@ -442,6 +453,7 @@ impl RemoteControlWebsocket { loop { let subscribe_cursor = self.state.lock().await.subscribe_cursor.clone(); let connect_options = RemoteControlConnectOptions { + installation_id: &self.installation_id, subscribe_cursor: subscribe_cursor.as_deref(), app_server_client_name, }; @@ -918,6 +930,7 @@ fn build_remote_control_websocket_request( websocket_url: &str, enrollment: &RemoteControlEnrollment, auth: &RemoteControlConnectionAuth, + installation_id: &str, subscribe_cursor: Option<&str>, ) -> io::Result> { let mut request = websocket_url.into_client_request().map_err(|err| { @@ -942,6 +955,11 @@ fn build_remote_control_websocket_request( auth.auth_provider.add_auth_headers(&mut auth_headers); headers.extend(auth_headers); set_remote_control_header(headers, REMOTE_CONTROL_ACCOUNT_ID_HEADER, &auth.account_id)?; + set_remote_control_header( + headers, + REMOTE_CONTROL_INSTALLATION_ID_HEADER, + installation_id, + )?; if let Some(subscribe_cursor) = subscribe_cursor { set_remote_control_header( headers, @@ -1066,7 +1084,12 @@ pub(super) async fn connect_remote_control_websocket( "creating new remote control enrollment: websocket_url={}, enroll_url={}, account_id={}", remote_control_target.websocket_url, remote_control_target.enroll_url, auth.account_id ); - let new_enrollment = match enroll_remote_control_server(remote_control_target, &auth).await + let new_enrollment = match enroll_remote_control_server( + remote_control_target, + &auth, + connect_options.installation_id, + ) + .await { Ok(new_enrollment) => new_enrollment, Err(err) @@ -1110,6 +1133,7 @@ pub(super) async fn connect_remote_control_websocket( &remote_control_target.websocket_url, enrollment_ref, &auth, + connect_options.installation_id, connect_options.subscribe_cursor, )?; @@ -1247,6 +1271,7 @@ mod tests { const TEST_HTTP_ACCEPT_TIMEOUT: Duration = Duration::from_secs(30); #[cfg(not(windows))] const TEST_HTTP_ACCEPT_TIMEOUT: Duration = Duration::from_secs(5); + const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; fn remote_control_status_channel() -> ( RemoteControlStatusPublisher, @@ -1254,6 +1279,7 @@ mod tests { ) { let (status_tx, status_rx) = watch::channel(RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, }); (RemoteControlStatusPublisher::new(status_tx), status_rx) @@ -1359,6 +1385,7 @@ mod tests { &mut auth_recovery, &mut enrollment, RemoteControlConnectOptions { + installation_id: TEST_INSTALLATION_ID, subscribe_cursor: None, app_server_client_name: None, }, @@ -1376,6 +1403,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_test".to_string()), } ); @@ -1435,6 +1463,7 @@ mod tests { &mut auth_recovery, &mut enrollment, RemoteControlConnectOptions { + installation_id: TEST_INSTALLATION_ID, subscribe_cursor: None, app_server_client_name: None, }, @@ -1448,6 +1477,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_test".to_string()), } ); @@ -1515,6 +1545,7 @@ mod tests { &mut auth_recovery, &mut enrollment, RemoteControlConnectOptions { + installation_id: TEST_INSTALLATION_ID, subscribe_cursor: None, app_server_client_name: None, }, @@ -1567,6 +1598,7 @@ mod tests { &mut auth_recovery, &mut enrollment, RemoteControlConnectOptions { + installation_id: TEST_INSTALLATION_ID, subscribe_cursor: None, app_server_client_name: None, }, @@ -1614,6 +1646,7 @@ mod tests { &mut auth_recovery, &mut enrollment, RemoteControlConnectOptions { + installation_id: TEST_INSTALLATION_ID, subscribe_cursor: None, app_server_client_name: None, }, @@ -1632,6 +1665,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } ); @@ -1656,8 +1690,11 @@ mod tests { let shutdown_token = shutdown_token.clone(); async move { RemoteControlWebsocket::new( - remote_control_url, - Some(remote_control_target), + RemoteControlWebsocketConfig { + remote_control_url, + installation_id: TEST_INSTALLATION_ID.to_string(), + remote_control_target: Some(remote_control_target), + }, /*state_db*/ None, remote_control_auth_manager(), RemoteControlChannels { @@ -1701,6 +1738,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_first".to_string()), } ); @@ -1721,6 +1759,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connected, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_first".to_string()), } ); @@ -1734,6 +1773,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connected, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } ); @@ -1748,6 +1788,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Disabled, + installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } ); diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 08aab99f65..91c7e801a7 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -31,6 +31,7 @@ use crate::outgoing_message::QueuedOutgoingMessage; use crate::transport::CHANNEL_CAPACITY; use crate::transport::ConnectionState; use crate::transport::OutboundConnectionState; +use crate::transport::RemoteControlStartConfig; use crate::transport::TransportEvent; use crate::transport::auth::policy_from_settings; use crate::transport::route_outgoing_envelope; @@ -686,7 +687,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(), @@ -977,6 +981,7 @@ pub async fn run_main_with_transport_options( .send_server_notification(ServerNotification::RemoteControlStatusChanged( RemoteControlStatusChangedNotification { status: status.status, + installation_id: status.installation_id, environment_id: status.environment_id, }, )) diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport.rs index 8d61ac5f56..4eae17e469 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport.rs @@ -19,6 +19,7 @@ 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; From 5f2543b74ef06b960dc1c1c42ac1c8219a8297f4 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Fri, 8 May 2026 11:17:56 -0700 Subject: [PATCH 03/27] Load configured environments from CODEX_HOME (#20667) ## Why The earlier PRs add stdio transport support and the config-backed environment provider, but the feature remains inert until normal Codex entrypoints construct `EnvironmentManager` with enough context to discover `CODEX_HOME/environments.toml`. This final stack PR activates the provider while preserving the legacy `CODEX_EXEC_SERVER_URL` fallback when no environments file exists. **Stack position:** this is PR 5 of 5. It is the product wiring PR that activates the configured environment provider added in PR 4. ## What Changed - Thread `codex_home` into `EnvironmentManagerArgs`. - Change `EnvironmentManager::new(...)` to load the provider from `CODEX_HOME`. - Preserve legacy behavior by falling back to `DefaultEnvironmentProvider::from_env()` when `environments.toml` is absent. - Make `environments.toml`-backed managers start new threads with all configured environments, default first, while keeping the legacy env-var path single-default. - Update the app-server, TUI, exec, MCP server, connector, prompt-debug, and thread-manager-sample callsites to pass `codex_home` and handle provider-loading errors. ## Self-Review Notes - The multi-environment startup path is intentionally tied to the `environments.toml` provider. Using `>1` configured environment as the only signal would also expand the legacy `CODEX_EXEC_SERVER_URL` provider because it keeps `local` addressable alongside `remote`. - The startup environment list is still derived inside `EnvironmentManager`; the provider only says whether its snapshot should start new threads with all configured environments. - The thread-manager sample was updated to pass the current `ThreadManager::new(...)` installation id argument so the stack compiles under Bazel. ## Stack - 1. https://github.com/openai/codex/pull/20663 - Add stdio exec-server listener - 2. https://github.com/openai/codex/pull/20664 - Add stdio exec-server client transport - 3. https://github.com/openai/codex/pull/20665 - Make environment providers own default selection - 4. https://github.com/openai/codex/pull/20666 - Add CODEX_HOME environments TOML provider - **5. This PR:** https://github.com/openai/codex/pull/20667 - Load configured environments from CODEX_HOME Split from original draft: https://github.com/openai/codex/pull/20508 ## Validation - `just fmt` - `git diff --check` - `bazel build --config=remote --strategy=remote --remote_download_toplevel //codex-rs/thread-manager-sample:codex-thread-manager-sample` - `bazel test --config=remote --strategy=remote --remote_download_toplevel //codex-rs/exec-server:exec-server-unit-tests` - `bazel test --config=remote --strategy=remote --remote_download_toplevel --test_sharding_strategy=disabled --test_arg=default_thread_environment_selections_use_manager_default_id //codex-rs/core:core-unit-tests` - `bazel test --config=remote --strategy=remote --remote_download_toplevel --test_sharding_strategy=disabled --test_arg=start_thread_uses_all_default_environments_from_codex_home //codex-rs/core:core-unit-tests` ## Documentation This activates `CODEX_HOME/environments.toml`; user-facing documentation should be added before this stack is treated as a documented public workflow. --------- Co-authored-by: Codex --- codex-rs/app-server-client/src/lib.rs | 1 - codex-rs/app-server/src/lib.rs | 21 +++-- codex-rs/core-api/src/lib.rs | 1 - codex-rs/core/src/connectors.rs | 3 +- codex-rs/core/src/environment_selection.rs | 20 ++-- codex-rs/core/src/prompt_debug.rs | 8 +- codex-rs/core/src/thread_manager_tests.rs | 94 +++++++++++++++++++ codex-rs/exec-server/src/environment.rs | 68 ++++++++++---- .../exec-server/src/environment_provider.rs | 62 +++++++----- codex-rs/exec-server/src/environment_toml.rs | 51 +++++----- codex-rs/exec-server/src/lib.rs | 1 - codex-rs/exec/src/lib.rs | 10 +- codex-rs/mcp-server/src/lib.rs | 21 +++-- codex-rs/thread-manager-sample/src/main.rs | 6 +- codex-rs/tui/src/app.rs | 4 + codex-rs/tui/src/app/tests.rs | 2 + codex-rs/tui/src/chatwidget.rs | 9 +- codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + .../tui/src/chatwidget/tests/plan_mode.rs | 1 + .../chatwidget/tests/popups_and_settings.rs | 1 + .../src/chatwidget/tests/status_and_layout.rs | 1 + codex-rs/tui/src/lib.rs | 43 +++++---- 22 files changed, 311 insertions(+), 118 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index ebafe351af..6dadec3b24 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; diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 91c7e801a7..fcb2335b3c 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; @@ -420,15 +419,6 @@ pub async fn run_main_with_transport_options( auth: AppServerWebsocketAuthSettings, runtime_options: AppServerRuntimeOptions, ) -> IoResult<()> { - let environment_manager = Arc::new( - EnvironmentManager::new(EnvironmentManagerArgs::new( - ExecServerRuntimePaths::from_optional_paths( - arg0_paths.codex_self_exe.clone(), - arg0_paths.codex_linux_sandbox_exe.clone(), - )?, - )) - .await, - ); let (transport_event_tx, mut transport_event_rx) = mpsc::channel::(CHANNEL_CAPACITY); let (outgoing_tx, mut outgoing_rx) = mpsc::channel::(CHANNEL_CAPACITY); @@ -444,6 +434,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(), diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index f9bdc9b56b..790079ec30 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -44,7 +44,6 @@ 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_features::Feature; pub use codex_features::Features; diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 4da588edb6..718b2d402a 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -15,7 +15,6 @@ pub use codex_app_server_protocol::AppMetadata; use codex_connectors::AllConnectorsCacheKey; use codex_connectors::DirectoryListResponse; use codex_exec_server::EnvironmentManager; -use codex_exec_server::EnvironmentManagerArgs; use codex_exec_server::ExecServerRuntimePaths; use codex_protocol::models::PermissionProfile; use codex_tools::DiscoverableTool; @@ -202,7 +201,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, diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index b4bd9cbe89..640d813243 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() } @@ -111,10 +111,16 @@ mod tests { assert_eq!( default_thread_environment_selections(&manager, &cwd), - vec![TurnEnvironmentSelection { - environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd, - }] + vec![ + TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: cwd.clone(), + }, + TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd, + }, + ] ); } diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 8717427afe..0b9e334e07 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -2,9 +2,9 @@ use std::collections::HashSet; use std::sync::Arc; use codex_exec_server::EnvironmentManager; -use codex_exec_server::EnvironmentManagerArgs; use codex_exec_server::ExecServerRuntimePaths; use codex_login::AuthManager; +use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; @@ -44,7 +44,11 @@ pub async fn build_prompt_input( &config, Arc::clone(&auth_manager), SessionSource::Exec, - Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await), + Arc::new( + EnvironmentManager::from_codex_home(config.codex_home.clone(), local_runtime_paths) + .await + .map_err(|err| CodexErr::Fatal(err.to_string()))?, + ), /*analytics_events_client*/ None, thread_store, state_db.clone(), diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 0834c18e21..683c8e6ab7 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -21,6 +21,7 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; +use codex_protocol::user_input::UserInput; use core_test_support::PathBufExt; use core_test_support::PathExt; use core_test_support::responses::mount_models_once; @@ -339,6 +340,99 @@ async fn start_thread_accepts_explicit_environment_when_default_environment_is_d assert_eq!(manager.list_thread_ids().await, vec![thread.thread_id]); } +#[tokio::test] +async fn start_thread_uses_all_default_environments_from_codex_home() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config().await; + config.codex_home = temp_dir.path().join("codex-home").abs(); + config.cwd = config.codex_home.abs(); + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + std::fs::write( + config.codex_home.join("environments.toml"), + r#" +default = "dev" + +[[environments]] +id = "dev" +program = "ssh" +args = ["dev", "cd /tmp && true"] +"#, + ) + .expect("write environments.toml"); + + let runtime_paths = codex_exec_server::ExecServerRuntimePaths::new( + std::env::current_exe().expect("current exe path"), + /*codex_linux_sandbox_exe*/ None, + ) + .expect("runtime paths"); + let environment_manager = Arc::new( + codex_exec_server::EnvironmentManager::from_codex_home( + config.codex_home.clone(), + runtime_paths, + ) + .await + .expect("environment manager"), + ); + assert_eq!( + environment_manager.default_environment_ids(), + vec!["dev".to_string(), "local".to_string()] + ); + + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + environment_manager, + ); + + let thread = manager + .start_thread(config) + .await + .expect("thread should start"); + + let prompt_items = crate::prompt_debug::build_prompt_input_from_session( + thread.thread.codex.session.as_ref(), + Vec::::new(), + ) + .await + .expect("prompt input"); + let environment_context = prompt_items + .iter() + .filter_map(|item| match item { + ResponseItem::Message { content, .. } => Some(content), + _ => None, + }) + .flatten() + .find_map(|content| match content { + ContentItem::InputText { text } if text.contains("") => { + Some(text.as_str()) + } + _ => None, + }) + .expect("environment context prompt item"); + assert!(environment_context.contains("")); + let cwd = thread.session_configured.cwd.display().to_string(); + let dev_entry = format!( + r#" + {cwd} + "# + ); + let local_entry = format!( + r#" + {cwd} + "# + ); + let dev_position = environment_context + .find(&dev_entry) + .expect("dev environment entry"); + let local_position = environment_context + .find(&local_entry) + .expect("local environment entry"); + assert!(dev_position < local_position); + assert!(!environment_context.contains("\n ")); + assert!(!environment_context.contains("\n ")); +} + #[tokio::test] async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() { let temp_dir = tempdir().expect("tempdir"); diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index d13ba6d3bc..be83393bb5 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -114,6 +114,15 @@ impl EnvironmentManager { Self::from_provider(provider.as_ref(), local_runtime_paths).await } + /// Builds a manager from the legacy environment-variable provider without + /// reading user config files from `CODEX_HOME`. + pub async fn from_env( + local_runtime_paths: ExecServerRuntimePaths, + ) -> Result { + let provider = DefaultEnvironmentProvider::from_env(); + Self::from_provider(&provider, local_runtime_paths).await + } + async fn from_default_provider_url( exec_server_url: Option, local_runtime_paths: ExecServerRuntimePaths, @@ -147,18 +156,26 @@ impl EnvironmentManager { environments, default, } = snapshot; - for id in environments.keys() { + let mut environment_map = HashMap::with_capacity(environments.len()); + for (id, environment) in environments { if id.is_empty() { return Err(ExecServerError::Protocol( "environment id cannot be empty".to_string(), )); } + if environment_map + .insert(id.clone(), Arc::new(environment)) + .is_some() + { + return Err(ExecServerError::Protocol(format!( + "environment id `{id}` is duplicated" + ))); + } } - let default_environment = match default { EnvironmentDefault::Disabled => None, EnvironmentDefault::EnvironmentId(environment_id) => { - if !environments.contains_key(&environment_id) { + if !environment_map.contains_key(&environment_id) { return Err(ExecServerError::Protocol(format!( "default environment `{environment_id}` is not configured" ))); @@ -167,14 +184,10 @@ impl EnvironmentManager { } }; let local_environment = Arc::new(Environment::local(local_runtime_paths)); - let environments = environments - .into_iter() - .map(|(id, environment)| (id, Arc::new(environment))) - .collect(); Ok(Self { default_environment, - environments, + environments: environment_map, local_environment, }) } @@ -191,6 +204,22 @@ impl EnvironmentManager { self.default_environment.as_deref() } + /// Returns the ordered environment ids used for new thread startup. + pub fn default_environment_ids(&self) -> Vec { + let Some(default_environment_id) = self.default_environment.as_ref() else { + return Vec::new(); + }; + let mut environment_ids = Vec::with_capacity(self.environments.len()); + environment_ids.push(default_environment_id.clone()); + environment_ids.extend( + self.environments + .keys() + .filter(|environment_id| *environment_id != default_environment_id) + .cloned(), + ); + environment_ids + } + /// Returns the local environment instance used for internal runtime work. pub fn local_environment(&self) -> Arc { Arc::clone(&self.local_environment) @@ -350,7 +379,6 @@ impl Environment { #[cfg(test)] mod tests { - use std::collections::HashMap; use std::sync::Arc; use super::Environment; @@ -472,11 +500,11 @@ mod tests { async fn environment_manager_builds_from_provider() { let provider = TestEnvironmentProvider { snapshot: EnvironmentProviderSnapshot { - environments: HashMap::from([( + environments: vec![( REMOTE_ENVIRONMENT_ID.to_string(), Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) .expect("remote environment"), - )]), + )], default: EnvironmentDefault::EnvironmentId(REMOTE_ENVIRONMENT_ID.to_string()), }, }; @@ -502,7 +530,7 @@ mod tests { async fn environment_manager_rejects_empty_environment_id() { let provider = TestEnvironmentProvider { snapshot: EnvironmentProviderSnapshot { - environments: HashMap::from([("".to_string(), Environment::default_for_tests())]), + environments: vec![("".to_string(), Environment::default_for_tests())], default: EnvironmentDefault::Disabled, }, }; @@ -520,7 +548,7 @@ mod tests { async fn environment_manager_uses_explicit_provider_default() { let provider = TestEnvironmentProvider { snapshot: EnvironmentProviderSnapshot { - environments: HashMap::from([ + environments: vec![ ( LOCAL_ENVIRONMENT_ID.to_string(), Environment::default_for_tests(), @@ -530,7 +558,7 @@ mod tests { Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) .expect("remote environment"), ), - ]), + ], default: EnvironmentDefault::EnvironmentId("devbox".to_string()), }, }; @@ -539,6 +567,10 @@ mod tests { .expect("manager"); assert_eq!(manager.default_environment_id(), Some("devbox")); + assert_eq!( + manager.default_environment_ids(), + vec!["devbox".to_string(), LOCAL_ENVIRONMENT_ID.to_string()] + ); assert!(manager.default_environment().expect("default").is_remote()); } @@ -546,10 +578,10 @@ mod tests { async fn environment_manager_disables_provider_default() { let provider = TestEnvironmentProvider { snapshot: EnvironmentProviderSnapshot { - environments: HashMap::from([( + environments: vec![( LOCAL_ENVIRONMENT_ID.to_string(), Environment::default_for_tests(), - )]), + )], default: EnvironmentDefault::Disabled, }, }; @@ -566,10 +598,10 @@ mod tests { async fn environment_manager_rejects_unknown_provider_default() { let provider = TestEnvironmentProvider { snapshot: EnvironmentProviderSnapshot { - environments: HashMap::from([( + environments: vec![( LOCAL_ENVIRONMENT_ID.to_string(), Environment::default_for_tests(), - )]), + )], default: EnvironmentDefault::EnvironmentId("missing".to_string()), }, }; diff --git a/codex-rs/exec-server/src/environment_provider.rs b/codex-rs/exec-server/src/environment_provider.rs index 0e4bcc5191..bced67db55 100644 --- a/codex-rs/exec-server/src/environment_provider.rs +++ b/codex-rs/exec-server/src/environment_provider.rs @@ -1,5 +1,3 @@ -use std::collections::HashMap; - use async_trait::async_trait; use crate::Environment; @@ -12,9 +10,9 @@ use crate::environment::REMOTE_ENVIRONMENT_ID; /// Lists the concrete environments available to Codex. /// /// Implementations own a startup snapshot containing both the available -/// environment list and default environment selection. Providers that want the -/// local environment to be addressable by id should include it explicitly in -/// the returned map. +/// environment list in configured order and the default environment +/// selection. Providers that want the local environment to be addressable by +/// id should include it explicitly in the returned list. #[async_trait] pub trait EnvironmentProvider: Send + Sync { /// Returns the provider-owned environment startup snapshot. @@ -26,7 +24,7 @@ pub trait EnvironmentProvider: Send + Sync { #[derive(Clone, Debug)] pub struct EnvironmentProviderSnapshot { - pub environments: HashMap, + pub environments: Vec<(String, Environment)>, pub default: EnvironmentDefault, } @@ -57,22 +55,25 @@ impl DefaultEnvironmentProvider { &self, local_runtime_paths: &ExecServerRuntimePaths, ) -> EnvironmentProviderSnapshot { - let mut environments = HashMap::from([( + let mut environments = vec![( LOCAL_ENVIRONMENT_ID.to_string(), Environment::local(local_runtime_paths.clone()), - )]); + )]; let (exec_server_url, disabled) = normalize_exec_server_url(self.exec_server_url.clone()); if let Some(exec_server_url) = exec_server_url { - environments.insert( + environments.push(( REMOTE_ENVIRONMENT_ID.to_string(), Environment::remote_inner(exec_server_url, Some(local_runtime_paths.clone())), - ); + )); } + let has_remote = environments + .iter() + .any(|(id, _environment)| id == REMOTE_ENVIRONMENT_ID); let default = if disabled { EnvironmentDefault::Disabled - } else if environments.contains_key(REMOTE_ENVIRONMENT_ID) { + } else if has_remote { EnvironmentDefault::EnvironmentId(REMOTE_ENVIRONMENT_ID.to_string()) } else { EnvironmentDefault::EnvironmentId(LOCAL_ENVIRONMENT_ID.to_string()) @@ -105,6 +106,8 @@ pub(crate) fn normalize_exec_server_url(exec_server_url: Option) -> (Opt #[cfg(test)] mod tests { + use std::collections::HashMap; + use pretty_assertions::assert_eq; use super::*; @@ -126,7 +129,11 @@ mod tests { .snapshot(&runtime_paths) .await .expect("environments"); - let environments = snapshot.environments; + let EnvironmentProviderSnapshot { + environments, + default, + } = snapshot; + let environments: HashMap<_, _> = environments.into_iter().collect(); assert!(!environments[LOCAL_ENVIRONMENT_ID].is_remote()); assert_eq!( @@ -135,7 +142,7 @@ mod tests { ); assert!(!environments.contains_key(REMOTE_ENVIRONMENT_ID)); assert_eq!( - snapshot.default, + default, EnvironmentDefault::EnvironmentId(LOCAL_ENVIRONMENT_ID.to_string()) ); } @@ -148,12 +155,16 @@ mod tests { .snapshot(&runtime_paths) .await .expect("environments"); - let environments = snapshot.environments; + let EnvironmentProviderSnapshot { + environments, + default, + } = snapshot; + let environments: HashMap<_, _> = environments.into_iter().collect(); assert!(!environments[LOCAL_ENVIRONMENT_ID].is_remote()); assert!(!environments.contains_key(REMOTE_ENVIRONMENT_ID)); assert_eq!( - snapshot.default, + default, EnvironmentDefault::EnvironmentId(LOCAL_ENVIRONMENT_ID.to_string()) ); } @@ -166,11 +177,15 @@ mod tests { .snapshot(&runtime_paths) .await .expect("environments"); - let environments = snapshot.environments; + let EnvironmentProviderSnapshot { + environments, + default, + } = snapshot; + let environments: HashMap<_, _> = environments.into_iter().collect(); assert!(!environments[LOCAL_ENVIRONMENT_ID].is_remote()); assert!(!environments.contains_key(REMOTE_ENVIRONMENT_ID)); - assert_eq!(snapshot.default, EnvironmentDefault::Disabled); + assert_eq!(default, EnvironmentDefault::Disabled); } #[tokio::test] @@ -181,7 +196,11 @@ mod tests { .snapshot(&runtime_paths) .await .expect("environments"); - let environments = snapshot.environments; + let EnvironmentProviderSnapshot { + environments, + default, + } = snapshot; + let environments: HashMap<_, _> = environments.into_iter().collect(); assert!(!environments[LOCAL_ENVIRONMENT_ID].is_remote()); let remote_environment = &environments[REMOTE_ENVIRONMENT_ID]; @@ -191,7 +210,7 @@ mod tests { Some("ws://127.0.0.1:8765") ); assert_eq!( - snapshot.default, + default, EnvironmentDefault::EnvironmentId(REMOTE_ENVIRONMENT_ID.to_string()) ); } @@ -200,13 +219,14 @@ mod tests { async fn default_provider_normalizes_exec_server_url() { let provider = DefaultEnvironmentProvider::new(Some(" ws://127.0.0.1:8765 ".to_string())); let runtime_paths = test_runtime_paths(); - let environments = provider + let snapshot = provider .snapshot(&runtime_paths) .await .expect("environments"); + let environments: HashMap<_, _> = snapshot.environments.into_iter().collect(); assert_eq!( - environments.environments[REMOTE_ENVIRONMENT_ID].exec_server_url(), + environments[REMOTE_ENVIRONMENT_ID].exec_server_url(), Some("ws://127.0.0.1:8765") ); } diff --git a/codex-rs/exec-server/src/environment_toml.rs b/codex-rs/exec-server/src/environment_toml.rs index 99808d7896..a1f328377a 100644 --- a/codex-rs/exec-server/src/environment_toml.rs +++ b/codex-rs/exec-server/src/environment_toml.rs @@ -44,7 +44,7 @@ struct EnvironmentToml { #[derive(Clone, Debug, PartialEq, Eq)] struct TomlEnvironmentProvider { default: EnvironmentDefault, - environments: HashMap, + environments: Vec<(String, ExecServerTransportParams)>, } impl TomlEnvironmentProvider { @@ -58,7 +58,7 @@ impl TomlEnvironmentProvider { config_dir: Option<&Path>, ) -> Result { let mut ids = HashSet::from([LOCAL_ENVIRONMENT_ID.to_string()]); - let mut environments = HashMap::with_capacity(config.environments.len()); + let mut environments = Vec::with_capacity(config.environments.len()); for item in config.environments { let (id, transport) = parse_environment_toml(item, config_dir)?; if !ids.insert(id.clone()) { @@ -66,7 +66,7 @@ impl TomlEnvironmentProvider { "environment id `{id}` is duplicated" ))); } - environments.insert(id, transport); + environments.push((id, transport)); } let default = normalize_default_environment_id(config.default.as_deref(), &ids)?; Ok(Self { @@ -82,19 +82,19 @@ impl EnvironmentProvider for TomlEnvironmentProvider { &self, local_runtime_paths: &ExecServerRuntimePaths, ) -> Result { - let mut environments = HashMap::from([( + let mut environments = Vec::with_capacity(self.environments.len() + 1); + environments.push(( LOCAL_ENVIRONMENT_ID.to_string(), Environment::local(local_runtime_paths.clone()), - )]); - + )); for (id, transport_params) in &self.environments { - environments.insert( + environments.push(( id.clone(), Environment::remote_with_transport( transport_params.clone(), Some(local_runtime_paths.clone()), ), - ); + )); } Ok(EnvironmentProviderSnapshot { @@ -302,15 +302,6 @@ mod tests { #[tokio::test] async fn toml_provider_adds_implicit_local_and_configured_environments() { - let ssh_transport = ExecServerTransportParams::StdioCommand(StdioExecServerCommand { - program: "ssh".to_string(), - args: vec![ - "dev".to_string(), - "codex exec-server --listen stdio".to_string(), - ], - env: HashMap::from([("CODEX_LOG".to_string(), "debug".to_string())]), - cwd: None, - }); let provider = TomlEnvironmentProvider::new(EnvironmentsToml { default: Some("ssh-dev".to_string()), environments: vec![ @@ -345,13 +336,21 @@ mod tests { environments, default, } = snapshot; + let environment_ids: Vec<_> = environments + .iter() + .map(|(id, _environment)| id.as_str()) + .collect(); + assert_eq!( + environment_ids, + vec![LOCAL_ENVIRONMENT_ID, "devbox", "ssh-dev"] + ); + let environments: HashMap<_, _> = environments.into_iter().collect(); assert!(!environments[LOCAL_ENVIRONMENT_ID].is_remote()); assert_eq!( environments["devbox"].exec_server_url(), Some("ws://127.0.0.1:8765") ); - assert_eq!(provider.environments["ssh-dev"], ssh_transport); assert!(environments["ssh-dev"].is_remote()); assert_eq!(environments["ssh-dev"].exec_server_url(), None); assert_eq!( @@ -483,7 +482,7 @@ mod tests { .expect("provider"); assert_eq!( - provider.environments["ssh-dev"], + provider.environments[0].1, ExecServerTransportParams::StdioCommand(StdioExecServerCommand { program: "ssh".to_string(), args: Vec::new(), @@ -686,8 +685,13 @@ default = "none" .snapshot(&test_runtime_paths()) .await .expect("environments"); + let environment_ids: Vec<_> = snapshot + .environments + .into_iter() + .map(|(id, _environment)| id) + .collect(); - assert!(snapshot.environments.contains_key(LOCAL_ENVIRONMENT_ID)); + assert!(environment_ids.contains(&LOCAL_ENVIRONMENT_ID.to_string())); assert_eq!(snapshot.default, EnvironmentDefault::Disabled); } @@ -702,7 +706,12 @@ default = "none" .snapshot(&test_runtime_paths()) .await .expect("environments"); + let environment_ids: Vec<_> = snapshot + .environments + .into_iter() + .map(|(id, _environment)| id) + .collect(); - assert!(snapshot.environments.contains_key(LOCAL_ENVIRONMENT_ID)); + assert!(environment_ids.contains(&LOCAL_ENVIRONMENT_ID.to_string())); } } diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index 85de8258f2..d8c147127c 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -39,7 +39,6 @@ pub use codex_file_system::RemoveOptions; pub use environment::CODEX_EXEC_SERVER_URL_ENV_VAR; pub use environment::Environment; pub use environment::EnvironmentManager; -pub use environment::EnvironmentManagerArgs; pub use environment::LOCAL_ENVIRONMENT_ID; pub use environment::REMOTE_ENVIRONMENT_ID; pub use environment_provider::DefaultEnvironmentProvider; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b035a19517..ef33c614f4 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -15,7 +15,6 @@ pub use cli::Command; pub use cli::ReviewArgs; use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; use codex_app_server_client::EnvironmentManager; -use codex_app_server_client::EnvironmentManagerArgs; use codex_app_server_client::ExecServerRuntimePaths; use codex_app_server_client::InProcessAppServerClient; use codex_app_server_client::InProcessClientStartArgs; @@ -509,6 +508,11 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result arg0_paths.codex_linux_sandbox_exe.clone(), )?; let state_db = codex_core::init_state_db(&config).await; + let environment_manager = if run_loader_overrides.ignore_user_config { + EnvironmentManager::from_env(local_runtime_paths).await? + } else { + EnvironmentManager::from_codex_home(config.codex_home.clone(), local_runtime_paths).await? + }; let in_process_start_args = InProcessClientStartArgs { arg0_paths, config: std::sync::Arc::new(config.clone()), @@ -518,9 +522,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result feedback: CodexFeedback::new(), log_db: None, state_db: state_db.clone(), - environment_manager: std::sync::Arc::new( - EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await, - ), + environment_manager: std::sync::Arc::new(environment_manager), config_warnings, session_source: SessionSource::Exec, enable_codex_api_key_env: true, diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index d86f67522a..aa560bbe6a 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -9,7 +9,6 @@ use codex_arg0::Arg0DispatchPaths; use codex_core::config::Config; use codex_core::resolve_installation_id; use codex_exec_server::EnvironmentManager; -use codex_exec_server::EnvironmentManagerArgs; use codex_exec_server::ExecServerRuntimePaths; use codex_login::default_client::set_default_client_residency_requirement; use codex_utils_cli::CliConfigOverrides; @@ -61,15 +60,6 @@ pub async fn run_main( arg0_paths: Arg0DispatchPaths, cli_config_overrides: CliConfigOverrides, ) -> 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, - ); // Parse CLI overrides once and derive the base Config eagerly so later // components do not need to work with raw TOML values. let cli_kv_overrides = cli_config_overrides.parse_overrides().map_err(|e| { @@ -85,6 +75,17 @@ pub async fn run_main( })?; set_default_client_residency_requirement(config.enforce_residency.value()); let state_db = codex_core::init_state_db(&config).await; + let environment_manager = Arc::new( + EnvironmentManager::from_codex_home( + config.codex_home.clone(), + ExecServerRuntimePaths::from_optional_paths( + arg0_paths.codex_self_exe.clone(), + arg0_paths.codex_linux_sandbox_exe.clone(), + )?, + ) + .await + .map_err(std::io::Error::other)?, + ); let otel = codex_core::otel_init::build_provider( &config, diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 6817f677e6..fa653ce459 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -20,7 +20,6 @@ use codex_core_api::Config; use codex_core_api::ConfigLayerStack; use codex_core_api::Constrained; use codex_core_api::EnvironmentManager; -use codex_core_api::EnvironmentManagerArgs; use codex_core_api::EventMsg; use codex_core_api::ExecServerRuntimePaths; use codex_core_api::Features; @@ -114,8 +113,9 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { config.codex_linux_sandbox_exe.clone(), )?; let thread_store = thread_store_from_config(&config, state_db.clone()); - let environment_manager = - Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await); + let environment_manager = Arc::new( + EnvironmentManager::from_codex_home(config.codex_home.clone(), local_runtime_paths).await?, + ); let installation_id = resolve_installation_id(&config.codex_home).await?; let thread_manager = ThreadManager::new( &config, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index d6d65b04a4..dff288493c 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -575,6 +575,7 @@ impl App { ) -> crate::chatwidget::ChatWidgetInit { crate::chatwidget::ChatWidgetInit { config: cfg, + environment_manager: self.environment_manager.clone(), frame_requester: tui.frame_requester(), app_event_tx: self.app_event_tx.clone(), workspace_command_runner: self.workspace_command_runner.clone(), @@ -739,6 +740,7 @@ impl App { .await; let init = crate::chatwidget::ChatWidgetInit { config: config.clone(), + environment_manager: environment_manager.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), workspace_command_runner: Some(workspace_command_runner.clone()), @@ -775,6 +777,7 @@ impl App { })?; let init = crate::chatwidget::ChatWidgetInit { config: config.clone(), + environment_manager: environment_manager.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), workspace_command_runner: Some(workspace_command_runner.clone()), @@ -816,6 +819,7 @@ impl App { })?; let init = crate::chatwidget::ChatWidgetInit { config: config.clone(), + environment_manager: environment_manager.clone(), frame_requester: tui.frame_requester(), app_event_tx: app_event_tx.clone(), workspace_command_runner: Some(workspace_command_runner.clone()), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 301808d155..b74bfab8f6 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -435,6 +435,7 @@ async fn enqueue_primary_thread_session_replays_turns_before_initial_prompt_subm let model = crate::legacy_core::test_support::get_model_offline(config.model.as_deref()); app.chat_widget = ChatWidget::new_with_app_event(ChatWidgetInit { config, + environment_manager: app.environment_manager.clone(), frame_requester: crate::tui::FrameRequester::test_dummy(), app_event_tx: app.app_event_tx.clone(), workspace_command_runner: None, @@ -4834,6 +4835,7 @@ async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() { let replacement = ChatWidget::new_with_app_event(ChatWidgetInit { config: app.config.clone(), + environment_manager: app.environment_manager.clone(), frame_requester: crate::tui::FrameRequester::test_dummy(), app_event_tx: app.app_event_tx.clone(), workspace_command_runner: None, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 68e59d9409..0e601e02c9 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -129,6 +129,7 @@ use codex_config::types::ApprovalsReviewer; use codex_config::types::Notifications; use codex_config::types::WindowsSandboxModeToml; use codex_core_skills::model::SkillMetadata; +use codex_exec_server::EnvironmentManager; use codex_features::FEATURES; use codex_features::Feature; #[cfg(test)] @@ -558,6 +559,7 @@ pub(crate) fn get_limits_duration(windows_minutes: i64) -> String { /// Common initialization parameters shared by all `ChatWidget` constructors. pub(crate) struct ChatWidgetInit { pub(crate) config: Config, + pub(crate) environment_manager: Arc, pub(crate) frame_requester: FrameRequester, pub(crate) app_event_tx: AppEventSender, /// App-server-backed runner used by status surfaces for workspace metadata probes. @@ -759,6 +761,7 @@ pub(crate) struct ChatWidget { /// where the overlay may briefly treat new tail content as already cached. active_cell_revision: u64, config: Config, + environment_manager: Arc, raw_output_mode: bool, /// Runtime value resolved by core. `config.service_tier` remains the explicit user choice. effective_service_tier: Option, @@ -4839,6 +4842,7 @@ impl ChatWidget { fn new_with_op_target(common: ChatWidgetInit, codex_op_target: CodexOpTarget) -> Self { let ChatWidgetInit { config, + environment_manager, frame_requester, app_event_tx, workspace_command_runner, @@ -4924,6 +4928,7 @@ impl ChatWidget { active_cell_revision: 0, raw_output_mode: config.tui_raw_output_mode, config, + environment_manager, effective_service_tier, skills_all: Vec::new(), skills_initial_state: None, @@ -7148,12 +7153,14 @@ impl ChatWidget { } let config = self.config.clone(); + let environment_manager = Arc::clone(&self.environment_manager); let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { let accessible_result = - match connectors::list_accessible_connectors_from_mcp_tools_with_options_and_status( + match connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( &config, force_refetch, + &environment_manager, ) .await { diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index e03eda919b..05f967b5a4 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -192,6 +192,7 @@ pub(super) async fn make_chatwidget_manual( raw_output_mode: cfg.tui_raw_output_mode, config: cfg, effective_service_tier, + environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), current_collaboration_mode, active_collaboration_mask, has_chatgpt_account: false, diff --git a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs index a5dd3d0eb7..bd8a1800f1 100644 --- a/codex-rs/tui/src/chatwidget/tests/plan_mode.rs +++ b/codex-rs/tui/src/chatwidget/tests/plan_mode.rs @@ -1536,6 +1536,7 @@ async fn make_startup_chat_with_cli_overrides( let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let init = ChatWidgetInit { config: cfg.clone(), + environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), frame_requester: FrameRequester::test_dummy(), app_event_tx: AppEventSender::new(unbounded_channel::().0), workspace_command_runner: None, diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index cd6fddf5e8..bb2bc1a967 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -70,6 +70,7 @@ async fn experimental_mode_plan_is_ignored_on_startup() { let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let init = ChatWidgetInit { config: cfg.clone(), + environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), frame_requester: FrameRequester::test_dummy(), app_event_tx: AppEventSender::new(unbounded_channel::().0), workspace_command_runner: None, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 93e3222a5e..df88bcec93 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -246,6 +246,7 @@ async fn helpers_are_available_and_do_not_panic() { let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let init = ChatWidgetInit { config: cfg.clone(), + environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), frame_requester: FrameRequester::test_dummy(), app_event_tx: tx, workspace_command_runner: None, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5622c59f65..ac5b489af1 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -8,7 +8,7 @@ use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; use crate::legacy_core::config::find_codex_home; -use crate::legacy_core::config::load_config_as_toml_with_cli_overrides; +use crate::legacy_core::config::load_config_as_toml_with_cli_and_loader_overrides; use crate::legacy_core::config::resolve_oss_provider; use crate::legacy_core::format_exec_policy_error_with_source; use crate::legacy_core::windows_sandbox::WindowsSandboxLevelExt; @@ -40,7 +40,6 @@ use codex_config::ConfigLoadError; use codex_config::LoaderOverrides; use codex_config::format_config_error_with_source; use codex_exec_server::EnvironmentManager; -use codex_exec_server::EnvironmentManagerArgs; use codex_exec_server::ExecServerRuntimePaths; use codex_login::AuthConfig; use codex_login::default_client::set_default_client_residency_requirement; @@ -661,10 +660,10 @@ fn config_cwd_for_app_server_target( app_server_target: &AppServerTarget, environment_manager: &EnvironmentManager, ) -> std::io::Result> { - if environment_manager - .default_environment() - .is_some_and(|environment| environment.is_remote()) - || matches!(app_server_target, AppServerTarget::Remote { .. }) + if matches!(app_server_target, AppServerTarget::Remote { .. }) + || environment_manager + .default_environment() + .is_some_and(|environment| environment.is_remote()) { return Ok(None); } @@ -678,6 +677,14 @@ fn config_cwd_for_app_server_target( Ok(Some(cwd)) } +fn should_load_configured_environments( + loader_overrides: &LoaderOverrides, + app_server_target: &AppServerTarget, +) -> bool { + !loader_overrides.ignore_user_config + && !matches!(app_server_target, AppServerTarget::Remote { .. }) +} + fn latest_session_cwd_filter<'a>( remote_mode: bool, remote_cwd_override: Option<&'a Path>, @@ -761,24 +768,28 @@ pub async fn run_main( } }; - 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 local_runtime_paths = ExecServerRuntimePaths::from_optional_paths( + arg0_paths.codex_self_exe.clone(), + arg0_paths.codex_linux_sandbox_exe.clone(), + )?; + let environment_manager = + if should_load_configured_environments(&loader_overrides, &app_server_target) { + EnvironmentManager::from_codex_home(codex_home.clone(), local_runtime_paths).await + } else { + EnvironmentManager::from_env(local_runtime_paths).await + } + .map(Arc::new) + .map_err(std::io::Error::other)?; let cwd = cli.cwd.clone(); let config_cwd = config_cwd_for_app_server_target(cwd.as_deref(), &app_server_target, &environment_manager)?; #[allow(clippy::print_stderr)] - let config_toml = match load_config_as_toml_with_cli_overrides( + let config_toml = match load_config_as_toml_with_cli_and_loader_overrides( &codex_home, config_cwd.as_ref(), cli_kv_overrides.clone(), + loader_overrides.clone(), ) .await { From 772e03459412015703c7a9e9a01b5049c3e7249c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 8 May 2026 21:37:23 +0300 Subject: [PATCH 04/27] Update models.json (#21776) Automated update of models.json. --------- Co-authored-by: aibrahim-oai <219906144+aibrahim-oai@users.noreply.github.com> Co-authored-by: Ahmed Ibrahim --- codex-rs/models-manager/models.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/codex-rs/models-manager/models.json b/codex-rs/models-manager/models.json index bd57d371b2..213fdcf6c4 100644 --- a/codex-rs/models-manager/models.json +++ b/codex-rs/models-manager/models.json @@ -176,11 +176,6 @@ "id": "priority", "name": "Fast", "description": "1.5x speed, increased usage" - }, - { - "id": "ultrafast", - "name": "Ultrafast", - "description": "The fastest available responses for latency-sensitive work." } ], "additional_speed_tiers": [ From 8956a928a13778f2fda835cb58853d9963257717 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 8 May 2026 22:00:44 +0300 Subject: [PATCH 05/27] Support resource binaries in Python runtime staging (#21787) ## Why Some Codex runtime distributions need helper executables beside the main bundled binary. Linux sandbox fallback needs a packaged `bwrap` when no suitable system `bwrap` is available, and Windows sandbox/elevation needs helper executables discoverable beside `codex.exe`. The checked-in `openai-codex-cli-bin` template already packages everything under `codex_cli_bin/bin/**`, but the staging script only copied the main Codex binary into that directory. This PR adds the generic staging primitive needed by release workflows to build complete platform runtime wheels without baking platform-specific helper names into the package template. ## What changed - Added repeatable `stage-runtime --resource-binary` support so release workflows can copy extra executables beside the bundled Codex binary. - Kept resource selection in workflow code, where the platform target is known. - Added tests that verify resource binaries are copied into the staged runtime package, that the wheel include config covers them, and that the CLI forwards repeated `--resource-binary` values. ## Testing - `uv run ruff check scripts/update_sdk_artifacts.py tests/test_artifact_workflow_and_binaries.py` - `uv run --extra dev pytest tests/test_artifact_workflow_and_binaries.py::test_stage_runtime_release_copies_resource_binaries tests/test_artifact_workflow_and_binaries.py::test_runtime_resource_binaries_are_included_by_wheel_config tests/test_artifact_workflow_and_binaries.py::test_stage_runtime_stages_binary_without_type_generation` Full `tests/test_artifact_workflow_and_binaries.py` still has unrelated schema-normalization drift in the local checkout. --------- Co-authored-by: Codex --- sdk/python/scripts/update_sdk_artifacts.py | 30 +++++++- .../test_artifact_workflow_and_binaries.py | 76 ++++++++++++++++++- 2 files changed, 103 insertions(+), 3 deletions(-) diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index 0d0e739c78..be9a115914 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -60,6 +60,13 @@ def staged_runtime_bin_path(root: Path) -> Path: return root / "src" / "codex_cli_bin" / "bin" / runtime_binary_name() +def staged_runtime_resource_path(root: Path, resource: Path) -> Path: + # Runtime wheels include the whole bin/ directory, so helper executables + # should be staged beside the main Codex binary instead of changing the + # package template for each platform. + return root / "src" / "codex_cli_bin" / "bin" / resource.name + + def run(cmd: list[str], cwd: Path) -> None: subprocess.run(cmd, cwd=str(cwd), check=True) @@ -211,6 +218,7 @@ def stage_python_runtime_package( codex_version: str, binary_path: Path, platform_tag: str | None = None, + resource_binaries: Sequence[Path] = (), ) -> Path: package_version = normalize_codex_version(codex_version) _copy_package_tree(python_runtime_root(), staging_dir) @@ -230,6 +238,16 @@ def stage_python_runtime_package( out_bin.chmod( out_bin.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH ) + for resource_binary in resource_binaries: + # Some release targets need helper executables beside the main binary + # (for example Linux bwrap or Windows sandbox helpers). Keep this + # generic so release workflows own the platform-specific list. + out_resource = staged_runtime_resource_path(staging_dir, resource_binary) + shutil.copy2(resource_binary, out_resource) + if not _is_windows(): + out_resource.chmod( + out_resource.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH + ) return staging_dir @@ -632,7 +650,9 @@ class PublicFieldSpec: class CliOps: generate_types: Callable[[], None] stage_python_sdk_package: Callable[[Path, str], Path] - stage_python_runtime_package: Callable[[Path, str, Path, str | None], Path] + stage_python_runtime_package: Callable[ + [Path, str, Path, str | None, Sequence[Path]], Path + ] current_sdk_version: Callable[[], str] @@ -1047,6 +1067,13 @@ def build_parser() -> argparse.ArgumentParser: "macosx_11_0_arm64 or musllinux_1_1_x86_64." ), ) + stage_runtime_parser.add_argument( + "--resource-binary", + action="append", + default=[], + type=Path, + help="Additional executable to package beside the codex runtime binary.", + ) return parser @@ -1101,6 +1128,7 @@ def run_command(args: argparse.Namespace, ops: CliOps) -> None: codex_version, args.runtime_binary.resolve(), args.platform_tag, + tuple(path.resolve() for path in args.resource_binary), ) diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index a30582517a..e9b4e6a8bb 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -8,6 +8,7 @@ import sys import tomllib import urllib.error from pathlib import Path +from typing import Sequence import pytest @@ -350,6 +351,62 @@ def test_stage_runtime_release_can_pin_wheel_platform_tag(tmp_path: Path) -> Non assert 'platform-tag = "musllinux_1_1_x86_64"' in pyproject +def test_stage_runtime_release_copies_resource_binaries(tmp_path: Path) -> None: + script = _load_update_script_module() + fake_binary = tmp_path / script.runtime_binary_name() + helper = tmp_path / "helper" + fallback = tmp_path / "fallback-helper" + fake_binary.write_text("fake codex\n") + helper.write_text("fake helper\n") + fallback.write_text("fake fallback\n") + + staged = script.stage_python_runtime_package( + tmp_path / "runtime-stage", + "1.2.3", + fake_binary, + resource_binaries=(helper, fallback), + ) + + assert { + path.relative_to( + staged / "src" / "codex_cli_bin" / "bin" + ).as_posix(): path.read_text() + for path in (staged / "src" / "codex_cli_bin" / "bin").iterdir() + } == { + script.runtime_binary_name(): "fake codex\n", + "fallback-helper": "fake fallback\n", + "helper": "fake helper\n", + } + + +def test_runtime_resource_binaries_are_included_by_wheel_config( + tmp_path: Path, +) -> None: + script = _load_update_script_module() + fake_binary = tmp_path / script.runtime_binary_name() + helper = tmp_path / "helper" + fake_binary.write_text("fake codex\n") + helper.write_text("fake helper\n") + + staged = script.stage_python_runtime_package( + tmp_path / "runtime-stage", + "1.2.3", + fake_binary, + resource_binaries=(helper,), + ) + + pyproject = tomllib.loads((staged / "pyproject.toml").read_text()) + assert { + "include": pyproject["tool"]["hatch"]["build"]["targets"]["wheel"]["include"], + "helper": ( + staged / "src" / "codex_cli_bin" / "bin" / "helper" + ).read_text(), + } == { + "include": ["src/codex_cli_bin/bin/**"], + "helper": "fake helper\n", + } + + def test_stage_sdk_release_injects_exact_runtime_pin(tmp_path: Path) -> None: script = _load_update_script_module() staged = script.stage_python_sdk_package( @@ -436,6 +493,7 @@ def test_stage_sdk_runs_type_generation_before_staging(tmp_path: Path) -> None: _runtime_version: str, _runtime_binary: Path, _platform_tag: str | None, + _resource_binaries: Sequence[Path], ) -> Path: raise AssertionError("runtime staging should not run for stage-sdk") @@ -476,7 +534,11 @@ def test_stage_sdk_rejects_mismatched_legacy_versions(tmp_path: Path) -> None: def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) -> None: script = _load_update_script_module() fake_binary = tmp_path / script.runtime_binary_name() + helper = tmp_path / "helper" + fallback = tmp_path / "fallback-helper" fake_binary.write_text("fake codex\n") + helper.write_text("fake helper\n") + fallback.write_text("fake fallback\n") calls: list[str] = [] args = script.parse_args( [ @@ -487,6 +549,10 @@ def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) -> "rust-v0.116.0-alpha.1", "--platform-tag", "musllinux_1_1_x86_64", + "--resource-binary", + str(helper), + "--resource-binary", + str(fallback), ] ) @@ -501,8 +567,12 @@ def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) -> codex_version: str, _runtime_binary: Path, platform_tag: str | None, + resource_binaries: Sequence[Path], ) -> Path: - calls.append(f"stage_runtime:{codex_version}:{platform_tag}") + calls.append( + f"stage_runtime:{codex_version}:{platform_tag}:" + f"{','.join(path.name for path in resource_binaries)}" + ) return tmp_path / "runtime-stage" def fake_current_sdk_version() -> str: @@ -517,7 +587,9 @@ def test_stage_runtime_stages_binary_without_type_generation(tmp_path: Path) -> script.run_command(args, ops) - assert calls == ["stage_runtime:0.116.0a1:musllinux_1_1_x86_64"] + assert calls == [ + "stage_runtime:0.116.0a1:musllinux_1_1_x86_64:helper,fallback-helper" + ] def test_default_runtime_is_resolved_from_installed_runtime_package( From 9183503b972c5b7aebd58dd3cc0c69e81c0d4631 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 8 May 2026 22:00:58 +0300 Subject: [PATCH 06/27] Publish Python runtime wheels on release (#21784) ## Why Published Python SDK builds depend on an exact `openai-codex-cli-bin` runtime package, but the release workflow did not publish that runtime package to PyPI. That left the SDK packaging story incomplete: release artifacts could produce Codex binaries, but Python users still needed a matching wheel carrying the platform-specific runtime and helper executables. This PR is stacked on #21787 so release jobs can include helper binaries in runtime wheels: Linux wheels include `bwrap` for sandbox fallback, and Windows wheels include the signed sandbox/elevation helpers beside `codex.exe`. ## What changed - Builds platform-specific `openai-codex-cli-bin` wheels from signed release binaries on macOS, Linux, and Windows release runners. - Packages Linux `bwrap` into musllinux runtime wheels. - Packages Windows sandbox helper executables into Windows runtime wheels. - Uploads runtime wheels as GitHub release assets and publishes them to PyPI using trusted publishing from the `pypi` GitHub environment. - Keeps the new Python runtime publish job non-blocking so failures need follow-up but do not fail the Rust release workflow. - Pins the PyPA publish action to the `v1.13.0` commit SHA for reproducible release publishing. - Documents that runtime wheels are platform wheels published through PyPI trusted publishing. ## Testing - `ruby -e 'require "yaml"; ARGV.each { |f| YAML.load_file(f); puts "ok #{f}" }' .github/workflows/rust-release.yml .github/workflows/rust-release-windows.yml` - `git diff --check` CI is the real end-to-end verification for the release workflow path. --------- Co-authored-by: Codex --- .github/workflows/rust-release-windows.yml | 41 ++++++++ .github/workflows/rust-release.yml | 114 +++++++++++++++++++++ sdk/python/README.md | 2 +- 3 files changed, 156 insertions(+), 1 deletion(-) diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 2eb7ef2a47..24cdebe454 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -220,6 +220,47 @@ 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 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" + python -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.yml b/.github/workflows/rust-release.yml index d05340a020..f958153f9c 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -399,6 +399,61 @@ 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 pip install build + + stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" + wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" + resource_args=() + 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. + resource_args+=( + --resource-binary + "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/bwrap" + ) + fi + python3 "${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" \ + "${resource_args[@]}" + python3 -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: | @@ -478,6 +533,7 @@ 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 @@ -554,6 +610,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: @@ -787,6 +859,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/sdk/python/README.md b/sdk/python/README.md index 149420ad95..031471b811 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -91,7 +91,7 @@ This supports the CI release flow: - run `generate-types` before packaging - stage `openai-codex-app-server-sdk` once with an exact `openai-codex-cli-bin==...` dependency - stage `openai-codex-cli-bin` on each supported platform runner with the same pinned runtime version -- build and publish `openai-codex-cli-bin` as platform wheels only; do not publish an sdist +- build and publish `openai-codex-cli-bin` as platform wheels only through PyPI trusted publishing; do not publish an sdist ## Compatibility and versioning From bbb6bf0a371ed913f5629a4a3602a6ed5e47a46d Mon Sep 17 00:00:00 2001 From: alexsong-oai Date: Fri, 8 May 2026 12:16:24 -0700 Subject: [PATCH 07/27] Emit accepted line fingerprint analytics (#21601) ## Why Codex assisted-code attribution needs a client-side accepted-code source that does not upload raw code. This adds a hash-only analytics event derived from the turn diff so downstream attribution can compare accepted Codex lines against commit or PR diffs. ## What Changed - Parse accepted/effective added lines from the final turn diff and emit `codex_accepted_line_fingerprints` analytics. - Hash repo, path, and normalized line content before upload; raw code and raw diffs are not included in the event. - Chunk large fingerprint payloads and send accepted-line fingerprint events in isolated requests while preserving normal batching for other analytics events. - Canonicalize Git remote URLs before repo hashing so SSH/HTTPS GitHub remotes join to the same repo hash. - Add parser coverage for unified diff hunk lines that look like `+++` or `---` file headers. ## Verification - `cargo test -p codex-analytics` - `cargo test -p codex-git-utils canonicalize_git_remote_url` - `just fix -p codex-analytics` - `just bazel-lock-check` - `git diff --check` --- codex-rs/analytics/src/accepted_lines.rs | 298 ++++++++++++++++++ .../analytics/src/analytics_client_tests.rs | 204 ++++++++++++ codex-rs/analytics/src/client.rs | 40 ++- codex-rs/analytics/src/client_tests.rs | 69 ++++ codex-rs/analytics/src/events.rs | 28 ++ codex-rs/analytics/src/facts.rs | 6 + codex-rs/analytics/src/lib.rs | 4 + codex-rs/analytics/src/reducer.rs | 114 +++++-- codex-rs/git-utils/src/info.rs | 145 +++++++++ codex-rs/git-utils/src/lib.rs | 1 + 10 files changed, 882 insertions(+), 27 deletions(-) create mode 100644 codex-rs/analytics/src/accepted_lines.rs diff --git a/codex-rs/analytics/src/accepted_lines.rs b/codex-rs/analytics/src/accepted_lines.rs new file mode 100644 index 0000000000..07a0e33694 --- /dev/null +++ b/codex-rs/analytics/src/accepted_lines.rs @@ -0,0 +1,298 @@ +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; + +const ACCEPTED_LINE_FINGERPRINT_EVENT_TARGET_BYTES: usize = 2 * 1024 * 1024; +const ACCEPTED_LINE_FINGERPRINT_EVENT_FIXED_BYTES: usize = 1024; + +#[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 chunks = accepted_line_fingerprint_chunks(input.line_fingerprints); + chunks + .into_iter() + .enumerate() + .map(|(index, line_fingerprints)| { + let is_first_chunk = index == 0; + TrackEventRequest::AcceptedLineFingerprints(Box::new( + CodexAcceptedLineFingerprintsEventRequest { + event_type: "codex_accepted_line_fingerprints", + event_params: CodexAcceptedLineFingerprintsEventParams { + event_type: input.event_type, + turn_id: input.turn_id.clone(), + thread_id: input.thread_id.clone(), + product_surface: input.product_surface.clone(), + model_slug: input.model_slug.clone(), + completed_at: input.completed_at, + repo_hash: input.repo_hash.clone(), + accepted_added_lines: if is_first_chunk { + input.accepted_added_lines + } else { + 0 + }, + accepted_deleted_lines: if is_first_chunk { + input.accepted_deleted_lines + } else { + 0 + }, + line_fingerprints, + }, + }, + )) + }) + .collect() +} + +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) +} + +fn accepted_line_fingerprint_chunks( + line_fingerprints: Vec, +) -> Vec> { + if line_fingerprints.is_empty() { + return vec![Vec::new()]; + } + + let mut chunks = Vec::new(); + let mut current = Vec::new(); + let mut current_bytes = ACCEPTED_LINE_FINGERPRINT_EVENT_FIXED_BYTES; + + for fingerprint in line_fingerprints { + let item_bytes = accepted_line_fingerprint_json_bytes(&fingerprint); + let separator_bytes = usize::from(!current.is_empty()); + if !current.is_empty() + && current_bytes + separator_bytes + item_bytes + > ACCEPTED_LINE_FINGERPRINT_EVENT_TARGET_BYTES + { + chunks.push(current); + current = Vec::new(); + current_bytes = ACCEPTED_LINE_FINGERPRINT_EVENT_FIXED_BYTES; + } + current_bytes += usize::from(!current.is_empty()) + item_bytes; + current.push(fingerprint); + } + + if !current.is_empty() { + chunks.push(current); + } + chunks +} + +fn accepted_line_fingerprint_json_bytes(fingerprint: &AcceptedLineFingerprint) -> usize { + // {"path_hash":"...","line_hash":"..."} plus one byte of array comma + // accounted for by the caller when needed. + 32 + fingerprint.path_hash.len() + fingerprint.line_hash.len() +} + +#[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..462b89c64a 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; @@ -28,6 +30,7 @@ use crate::events::codex_hook_run_metadata; use crate::events::codex_plugin_metadata; use crate::events::codex_plugin_used_metadata; use crate::events::subagent_thread_started_event_request; +use crate::facts::AcceptedLineFingerprint; use crate::facts::AnalyticsFact; use crate::facts::AnalyticsJsonRpcError; use crate::facts::AppInvocation; @@ -89,6 +92,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; @@ -827,6 +831,206 @@ 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![AcceptedLineFingerprint { + path_hash: "path-hash-1".to_string(), + line_hash: "line-hash-1".to_string(), + }], + }, + }, + )); + + 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": [ + { + "path_hash": "path-hash-1", + "line_hash": "line-hash-1" + } + ] + } + }) + ); +} + +#[tokio::test] +async fn reducer_chunks_large_accepted_line_fingerprint_events_without_repeating_counts() { + 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!(accepted_line_events.len() > 1); + let mut total_fingerprints = 0; + for (index, event) in accepted_line_events.iter().enumerate() { + assert_eq!(event.event_params.turn_id, "turn-2"); + assert_eq!(event.event_params.thread_id, "thread-2"); + total_fingerprints += event.event_params.line_fingerprints.len(); + if index == 0 { + assert_eq!(event.event_params.accepted_added_lines, 20_000); + assert_eq!(event.event_params.accepted_deleted_lines, 0); + } else { + assert_eq!(event.event_params.accepted_added_lines, 0); + assert_eq!(event.event_params.accepted_deleted_lines, 0); + } + assert!(serde_json::to_vec(event).expect("serialize chunk").len() < 2_100_000); + } + assert_eq!(total_fingerprints, 20_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_eq!(event.event_params.line_fingerprints.len(), 1); + assert_eq!( + event.event_params.line_fingerprints[0].line_hash, + crate::fingerprint_hash("line", "let latest_value = 2;") + ); +} + #[test] fn compaction_event_serializes_expected_shape() { let event = TrackEventRequest::Compaction(Box::new(CodexCompactionEventRequest { diff --git a/codex-rs/analytics/src/client.rs b/codex-rs/analytics/src/client.rs index 6d46b2ce57..d67f825b17 100644 --- a/codex-rs/analytics/src/client.rs +++ b/codex-rs/analytics/src/client.rs @@ -30,6 +30,7 @@ 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 std::collections::HashSet; @@ -352,6 +353,7 @@ impl AnalyticsEventsClient { notification, ServerNotification::TurnStarted(_) | ServerNotification::TurnCompleted(_) + | ServerNotification::TurnDiffUpdated(_) | ServerNotification::ItemStarted(_) | ServerNotification::ItemCompleted(_) | ServerNotification::ItemGuardianApprovalReviewStarted(_) @@ -371,6 +373,7 @@ async fn send_track_events( if events.is_empty() { return; } + let Some(auth) = auth_manager.auth().await else { return; }; @@ -380,12 +383,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..2ddc21e477 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -1,6 +1,14 @@ 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::AcceptedLineFingerprint; 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 +39,47 @@ 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![AcceptedLineFingerprint { + path_hash: "path-hash".to_string(), + line_hash: "line-hash".to_string(), + }], + }, + }, + )) +} + +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 +271,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 eaa7daf8f8..9925afc6d5 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; @@ -71,6 +72,7 @@ pub(crate) enum TrackEventRequest { CollabAgentToolCall(CodexCollabAgentToolCallEventRequest), WebSearch(CodexWebSearchEventRequest), ImageGeneration(CodexImageGenerationEventRequest), + AcceptedLineFingerprints(Box), #[allow(dead_code)] ReviewEvent(CodexReviewEventRequest), PluginUsed(CodexPluginUsedEventRequest), @@ -80,6 +82,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, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index d0446e8c0c..8ff4baea83 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -28,6 +28,12 @@ use codex_protocol::protocol::TokenUsage; 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, 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 2ddb59c0cf..f308eabb65 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; @@ -104,6 +108,7 @@ use codex_protocol::protocol::TokenUsage; use sha1::Digest; use std::collections::HashMap; use std::path::Path; +use std::path::PathBuf; #[derive(Default)] pub(crate) struct AnalyticsReducer { @@ -264,6 +269,7 @@ struct TurnState { started_at: Option, token_usage: Option, completed: Option, + latest_diff: Option, steer_count: usize, } @@ -305,7 +311,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,7 +323,7 @@ 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, @@ -338,10 +344,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; @@ -473,7 +479,7 @@ impl AnalyticsReducer { } } - fn ingest_turn_resolved_config( + async fn ingest_turn_resolved_config( &mut self, input: TurnResolvedConfigFact, out: &mut Vec, @@ -489,15 +495,16 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + latest_diff: None, steer_count: 0, }); 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, @@ -511,11 +518,12 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + latest_diff: None, steer_count: 0, }); 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( @@ -622,7 +630,7 @@ impl AnalyticsReducer { }); } - fn ingest_response( + async fn ingest_response( &mut self, connection_id: u64, response: ClientResponse, @@ -674,12 +682,13 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + latest_diff: None, steer_count: 0, }); 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, @@ -741,7 +750,7 @@ impl AnalyticsReducer { ); } - fn ingest_notification( + async fn ingest_notification( &mut self, notification: ServerNotification, out: &mut Vec, @@ -812,6 +821,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + latest_diff: None, steer_count: 0, }); turn_state.started_at = notification @@ -819,6 +829,24 @@ impl AnalyticsReducer { .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, + }); + turn_state.thread_id = Some(notification.thread_id); + turn_state.latest_diff = Some(notification.diff); + } ServerNotification::TurnCompleted(notification) => { let turn_state = self.turns @@ -831,6 +859,7 @@ impl AnalyticsReducer { started_at: None, token_usage: None, completed: None, + latest_diff: None, steer_count: 0, }); turn_state.completed = Some(CompletedTurnState { @@ -850,7 +879,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; } _ => {} } @@ -986,7 +1015,7 @@ impl AnalyticsReducer { })); } - fn maybe_emit_turn_event(&mut self, turn_id: &str, out: &mut Vec) { + 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; }; @@ -1019,18 +1048,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); } @@ -1642,6 +1676,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, diff --git a/codex-rs/git-utils/src/info.rs b/codex-rs/git-utils/src/info.rs index 067dd15869..c6656b8fa5 100644 --- a/codex-rs/git-utils/src/info.rs +++ b/codex-rs/git-utils/src/info.rs @@ -158,6 +158,108 @@ pub async fn get_head_commit_hash(cwd: &Path) -> Option { } } +pub fn canonicalize_git_remote_url(url: &str) -> Option { + let url = trim_git_suffix(url.trim().trim_end_matches('/')); + if url.is_empty() { + return None; + } + + if let Some((scheme, rest)) = url.split_once("://") { + return canonicalize_git_url_like_remote(scheme, rest); + } + + if let Some((host_part, path)) = parse_scp_like_remote(url) { + return canonicalize_git_remote_host_path(host_part, path, /*default_port*/ None); + } + + let (host_part, path) = url.split_once('/')?; + canonicalize_git_remote_host_path(host_part, path, /*default_port*/ None) +} + +fn canonicalize_git_url_like_remote(scheme: &str, rest: &str) -> Option { + let default_port = match scheme { + "git" => Some("9418"), + "http" => Some("80"), + "https" => Some("443"), + "ssh" => Some("22"), + _ => return None, + }; + + let rest = rest + .find(['?', '#']) + .map_or(rest, |suffix_index| &rest[..suffix_index]); + let (host_part, path) = rest.split_once('/')?; + canonicalize_git_remote_host_path(host_part, path, default_port) +} + +fn parse_scp_like_remote(remote: &str) -> Option<(&str, &str)> { + if remote.contains('/') + && remote + .find('/') + .is_some_and(|slash| remote.find(':').is_none_or(|colon| slash < colon)) + { + return None; + } + + let (host_part, path) = remote.split_once(':')?; + if host_part.is_empty() || path.is_empty() { + return None; + } + Some((host_part, path)) +} + +fn canonicalize_git_remote_host_path( + host_part: &str, + path: &str, + default_port: Option<&str>, +) -> Option { + let host = normalize_remote_host( + host_part + .rsplit_once('@') + .map_or(host_part, |(_, host)| host) + .trim() + .trim_end_matches('/'), + default_port, + ); + if host.is_empty() { + return None; + } + + let path = trim_git_suffix(path.trim().trim_matches('/')); + let components = path + .split('/') + .filter(|component| !component.is_empty()) + .collect::>(); + let [owner, repo, ..] = components.as_slice() else { + return None; + }; + if matches!((*owner, *repo), ("." | "..", _) | (_, "." | "..")) { + return None; + } + let path = components.join("/"); + + if host == "github.com" { + Some(format!("{host}/{}", path.to_ascii_lowercase())) + } else { + Some(format!("{host}/{path}")) + } +} + +fn normalize_remote_host(host: &str, default_port: Option<&str>) -> String { + let host = host.to_ascii_lowercase(); + if let Some(default_port) = default_port + && let Some((host_without_port, port)) = host.rsplit_once(':') + && port == default_port + { + return host_without_port.to_string(); + } + host +} + +fn trim_git_suffix(value: &str) -> &str { + value.strip_suffix(".git").unwrap_or(value) +} + pub async fn get_has_changes(cwd: &Path) -> Option { let output = run_git_command_with_timeout(&["status", "--porcelain"], cwd).await?; if !output.status.success() { @@ -724,3 +826,46 @@ pub async fn current_branch_name(cwd: &Path) -> Option { .map(|s| s.trim().to_string()) .filter(|name| !name.is_empty()) } + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn canonicalize_git_remote_url_normalizes_github_variants() { + for remote in [ + "git@github.com:OpenAI/Codex.git", + "ssh://git@github.com/openai/codex.git", + "ssh://git@github.com:22/OpenAI/Codex.git", + "https://github.com/openai/codex.git", + "https://github.com:443/openai/codex.git", + "https://token@github.com/openai/codex/", + "github.com/OpenAI/Codex.git", + ] { + assert_eq!( + canonicalize_git_remote_url(remote), + Some("github.com/openai/codex".to_string()) + ); + } + } + + #[test] + fn canonicalize_git_remote_url_handles_ghe_without_lowercasing_path() { + assert_eq!( + canonicalize_git_remote_url("git@ghe.company.com:Org/Repo.git"), + Some("ghe.company.com/Org/Repo".to_string()) + ); + assert_eq!( + canonicalize_git_remote_url("ssh://git@ghe.company.com:2222/Org/Repo.git"), + Some("ghe.company.com:2222/Org/Repo".to_string()) + ); + } + + #[test] + fn canonicalize_git_remote_url_rejects_non_repository_values() { + for remote in ["", "file:///tmp/repo", "github.com/openai", "/tmp/repo"] { + assert_eq!(canonicalize_git_remote_url(remote), None); + } + } +} diff --git a/codex-rs/git-utils/src/lib.rs b/codex-rs/git-utils/src/lib.rs index 63eaf586d5..bcd1a8b5c9 100644 --- a/codex-rs/git-utils/src/lib.rs +++ b/codex-rs/git-utils/src/lib.rs @@ -24,6 +24,7 @@ pub use errors::GitToolingError; pub use info::CommitLogEntry; pub use info::GitDiffToRemote; pub use info::GitInfo; +pub use info::canonicalize_git_remote_url; pub use info::collect_git_info; pub use info::current_branch_name; pub use info::default_branch_name; From 61142b61693dd89fc0104946ecb23c177a360905 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 8 May 2026 12:17:48 -0700 Subject: [PATCH 08/27] Remove ToolName display helper (#21465) ## Why `ToolName::display()` made it too easy to flatten tool identity and accidentally compare rendered strings. Tool identity should stay structural until a legacy string boundary actually requires the flattened spelling. ## What - Removes `ToolName::display()` and relies on the existing `Display` impl for messages and errors. - Adds structural ordering for `ToolName` and uses it for sorting/deduping deferred tools. - Carries `ToolName` through tool/sandbox plumbing, flattening only at legacy boundaries such as hook payloads, telemetry tags, and Responses tool names. - Updates MCP normalization tests to assert `ToolName` structure instead of rendered strings. ## Testing - `cargo test -p codex-mcp test_normalize_tools` - `cargo test -p codex-core unavailable_tool` - `just fix -p codex-protocol` - `just fix -p codex-mcp` - `just fix -p codex-core` --- .../codex-mcp/src/connection_manager_tests.rs | 34 ++++++++++--------- codex-rs/core/src/memory_usage.rs | 5 +-- codex-rs/core/src/session/tests.rs | 2 +- codex-rs/core/src/stream_events_utils.rs | 2 +- .../core/src/tools/handlers/apply_patch.rs | 4 +-- codex-rs/core/src/tools/handlers/mcp.rs | 10 ++++-- codex-rs/core/src/tools/handlers/shell.rs | 5 +-- .../tools/handlers/shell/container_exec.rs | 2 +- .../src/tools/handlers/shell/local_shell.rs | 2 +- .../src/tools/handlers/shell/shell_command.rs | 6 ++-- .../src/tools/handlers/shell/shell_handler.rs | 2 +- .../src/tools/handlers/unavailable_tool.rs | 2 +- codex-rs/core/src/tools/mod.rs | 18 ++++++++++ codex-rs/core/src/tools/orchestrator.rs | 13 ++++--- codex-rs/core/src/tools/parallel.rs | 5 ++- codex-rs/core/src/tools/registry.rs | 20 ++++++----- codex-rs/core/src/tools/runtimes/shell.rs | 3 +- .../core/src/tools/runtimes/unified_exec.rs | 3 +- codex-rs/core/src/tools/sandboxing.rs | 3 +- codex-rs/core/src/tools/spec.rs | 3 +- codex-rs/core/src/tools/spec_plan.rs | 2 +- codex-rs/core/src/tools/tool_search_entry.rs | 6 ++-- codex-rs/core/src/unavailable_tool.rs | 11 +++--- .../core/src/unified_exec/process_manager.rs | 3 +- codex-rs/protocol/src/tool_name.rs | 28 +++++++++++---- 25 files changed, 123 insertions(+), 71 deletions(-) diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 4835bc5705..61be90ed7e 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -93,6 +93,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!({ @@ -334,17 +346,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:?}" ); } @@ -363,10 +372,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"); @@ -375,10 +383,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:?}" ); } @@ -425,10 +430,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:?}" ); } diff --git a/codex-rs/core/src/memory_usage.rs b/codex-rs/core/src/memory_usage.rs index 02f74ea593..fd54044f21 100644 --- a/codex-rs/core/src/memory_usage.rs +++ b/codex-rs/core/src/memory_usage.rs @@ -1,5 +1,6 @@ use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::flat_tool_name; use crate::tools::handlers::unified_exec::ExecCommandArgs; use codex_memories_read::usage::MEMORIES_USAGE_METRIC; use codex_memories_read::usage::memories_usage_kinds_from_command; @@ -17,14 +18,14 @@ pub(crate) async fn emit_metric_for_tool_read(invocation: &ToolInvocation, succe } let success = if success { "true" } else { "false" }; - let tool_name = invocation.tool_name.display(); + let tool_name = flat_tool_name(&invocation.tool_name); for kind in kinds { invocation.turn.session_telemetry.counter( MEMORIES_USAGE_METRIC, /*inc*/ 1, &[ ("kind", kind.as_tag()), - ("tool", &tool_name), + ("tool", tool_name.as_ref()), ("success", success), ], ); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b63b16cbf4..d3bbeb90c7 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1025,7 +1025,7 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an session: Arc::clone(&session), turn: Arc::clone(&turn), call_id: "probe-call".to_string(), - tool_name: "probe".to_string(), + tool_name: codex_tools::ToolName::plain("probe"), }; orchestrator diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 29884da4ae..14a4e975ae 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -239,7 +239,7 @@ pub(crate) async fn handle_output_item_done( tracing::info!( thread_id = %ctx.sess.conversation_id, "ToolCall: {} {}", - call.tool_name.display(), + call.tool_name, payload_preview ); diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 2b63c1cb17..66b04f8e73 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -454,7 +454,7 @@ impl ToolHandler for ApplyPatchHandler { session: session.clone(), turn: turn.clone(), call_id: call_id.clone(), - tool_name: tool_name.display(), + tool_name: tool_name.clone(), }; let out = orchestrator .run( @@ -566,7 +566,7 @@ pub(crate) async fn intercept_apply_patch( session: session.clone(), turn: turn.clone(), call_id: call_id.to_string(), - tool_name: tool_name.to_string(), + tool_name: ToolName::plain(tool_name), }; let out = orchestrator .run( diff --git a/codex-rs/core/src/tools/handlers/mcp.rs b/codex-rs/core/src/tools/handlers/mcp.rs index 4dfcb44b1f..35dbc3bc01 100644 --- a/codex-rs/core/src/tools/handlers/mcp.rs +++ b/codex-rs/core/src/tools/handlers/mcp.rs @@ -8,6 +8,7 @@ use crate::tools::context::McpToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +use crate::tools::flat_tool_name; use crate::tools::hook_names::HookToolName; use crate::tools::registry::PostToolUsePayload; use crate::tools::registry::PreToolUsePayload; @@ -42,8 +43,9 @@ impl ToolHandler for McpHandler { return None; }; + let tool_name = &self.tool_name; Some(PreToolUsePayload { - tool_name: HookToolName::new(self.tool_name.display()), + tool_name: HookToolName::new(flat_tool_name(tool_name).into_owned()), tool_input: mcp_hook_tool_input(raw_arguments), }) } @@ -59,8 +61,9 @@ impl ToolHandler for McpHandler { let tool_response = result.post_tool_use_response(&invocation.call_id, &invocation.payload)?; + let tool_name = &self.tool_name; Some(PostToolUsePayload { - tool_name: HookToolName::new(self.tool_name.display()), + tool_name: HookToolName::new(flat_tool_name(tool_name).into_owned()), tool_use_id: invocation.call_id.clone(), tool_input: result.tool_input.clone(), tool_response, @@ -93,13 +96,14 @@ impl ToolHandler for McpHandler { let arguments_str = raw_arguments; let started = Instant::now(); + let hook_tool_name = flat_tool_name(&self.tool_name); let result = handle_mcp_tool_call( Arc::clone(&session), &turn, call_id.clone(), server, tool, - self.tool_name.display(), + hook_tool_name.into_owned(), arguments_str, ) .await; diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index f6960bca41..81a590e07d 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -29,6 +29,7 @@ use crate::tools::runtimes::shell::ShellRuntimeBackend; use crate::tools::sandboxing::ToolCtx; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::ExecCommandSource; +use codex_tools::ToolName; mod container_exec; mod local_shell; @@ -72,7 +73,7 @@ fn shell_command_payload_command(payload: &ToolPayload) -> Option { } struct RunExecLikeArgs { - tool_name: String, + tool_name: ToolName, exec_params: ExecParams, hook_command: String, additional_permissions: Option, @@ -201,7 +202,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result Ok(FunctionToolOutput::from_text( unavailable_tool_message( - self.tool_name.display(), + &self.tool_name, "Retry after the tool becomes available or ask the user to re-enable it.", ), Some(false), diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 812c365113..3073d9f9da 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -17,7 +17,10 @@ pub(crate) mod spec_plan_types; pub(crate) mod tool_dispatch_trace; pub(crate) mod tool_search_entry; +use std::borrow::Cow; + use codex_protocol::exec_output::ExecToolCallOutput; +use codex_tools::ToolName; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::formatted_truncate_text; use codex_utils_output_truncation::truncate_text; @@ -30,6 +33,21 @@ pub(crate) const TELEMETRY_PREVIEW_MAX_LINES: usize = 64; // lines pub(crate) const TELEMETRY_PREVIEW_TRUNCATION_NOTICE: &str = "[... telemetry preview truncated ...]"; +/// Legacy boundaries such as hook payloads, telemetry tags, and Responses tool +/// names still require a single flattened string. Keep comparisons and sorting +/// on `ToolName` itself; use this only when crossing those boundaries. +pub(crate) fn flat_tool_name(tool_name: &ToolName) -> Cow<'_, str> { + match tool_name.namespace.as_deref() { + Some(namespace) => { + let mut name = String::with_capacity(namespace.len() + tool_name.name.len()); + name.push_str(namespace); + name.push_str(&tool_name.name); + Cow::Owned(name) + } + None => Cow::Borrowed(tool_name.name.as_str()), + } +} + /// Format the combined exec output for sending back to the model. /// Includes exit code and duration metadata; truncates large bodies safely. pub fn format_exec_output_for_model_structured( diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index c618d778d6..4fd1ea17f4 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -12,6 +12,7 @@ use crate::guardian::new_guardian_review_id; use crate::guardian::routes_approval_to_guardian; use crate::hook_runtime::run_permission_request_hooks; use crate::network_policy_decision::network_approval_context_from_payload; +use crate::tools::flat_tool_name; use crate::tools::network_approval::ActiveNetworkApproval; use crate::tools::network_approval::DeferredNetworkApproval; use crate::tools::network_approval::NetworkApprovalMode; @@ -135,7 +136,7 @@ impl ToolOrchestrator { T: ToolRuntime, { let otel = turn_ctx.session_telemetry.clone(); - let otel_tn = &tool_ctx.tool_name; + let otel_tn = flat_tool_name(&tool_ctx.tool_name).into_owned(); let otel_ci = &tool_ctx.call_id; let strict_auto_review = tool_ctx.session.strict_auto_review_enabled_for_turn().await; let use_guardian = routes_approval_to_guardian(turn_ctx) || strict_auto_review; @@ -175,7 +176,7 @@ impl ToolOrchestrator { already_approved = true; } else { otel.tool_decision( - otel_tn, + &otel_tn, otel_ci, &ReviewDecision::Approved, ToolDecisionSource::Config, @@ -398,6 +399,7 @@ impl ToolOrchestrator { if evaluate_permission_request_hooks && let Some(permission_request) = tool.permission_request_payload(req) { + let tool_name = flat_tool_name(&tool_ctx.tool_name); match run_permission_request_hooks( approval_ctx.session, approval_ctx.turn, @@ -409,7 +411,7 @@ impl ToolOrchestrator { Some(PermissionRequestDecision::Allow) => { let decision = ReviewDecision::Approved; otel.tool_decision( - &tool_ctx.tool_name, + tool_name.as_ref(), &tool_ctx.call_id, &decision, ToolDecisionSource::Config, @@ -419,7 +421,7 @@ impl ToolOrchestrator { Some(PermissionRequestDecision::Deny { message }) => { let decision = ReviewDecision::Denied; otel.tool_decision( - &tool_ctx.tool_name, + tool_name.as_ref(), &tool_ctx.call_id, &decision, ToolDecisionSource::Config, @@ -436,8 +438,9 @@ impl ToolOrchestrator { ToolDecisionSource::User }; let decision = tool.start_approval_async(req, approval_ctx).await; + let tool_name = flat_tool_name(&tool_ctx.tool_name); otel.tool_decision( - &tool_ctx.tool_name, + tool_name.as_ref(), &tool_ctx.call_id, &decision, otel_source, diff --git a/codex-rs/core/src/tools/parallel.rs b/codex-rs/core/src/tools/parallel.rs index 384a800b4b..d7fe22e4f4 100644 --- a/codex-rs/core/src/tools/parallel.rs +++ b/codex-rs/core/src/tools/parallel.rs @@ -94,12 +94,11 @@ impl ToolCallRuntime { let lock = Arc::clone(&self.parallel_execution); let invocation_cancellation_token = cancellation_token.clone(); let started = Instant::now(); - let display_name = call.tool_name.display(); let dispatch_span = trace_span!( "dispatch_tool_call_with_code_mode_result", - otel.name = display_name.as_str(), - tool_name = display_name.as_str(), + otel.name = %call.tool_name, + tool_name = %call.tool_name, call_id = call.call_id.as_str(), aborted = false, ); diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index c1b5854b68..89c5ea0bf7 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -16,6 +16,7 @@ use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolOutput; use crate::tools::context::ToolPayload; +use crate::tools::flat_tool_name; use crate::tools::hook_names::HookToolName; use crate::tools::tool_dispatch_trace::ToolDispatchTrace; use crate::util::error_or_panic; @@ -272,7 +273,7 @@ impl ToolRegistry { invocation: ToolInvocation, ) -> Result { let tool_name = invocation.tool_name.clone(); - let display_name = tool_name.display(); + let tool_name_flat = flat_tool_name(&tool_name); let call_id_owned = invocation.call_id.clone(); let otel = invocation.turn.session_telemetry.clone(); let payload_for_response = invocation.payload.clone(); @@ -325,7 +326,7 @@ impl ToolRegistry { None => { let message = unsupported_tool_call_message(&invocation.payload, &tool_name); otel.tool_result_with_tags( - &display_name, + tool_name_flat.as_ref(), &call_id_owned, log_payload.as_ref(), Duration::ZERO, @@ -342,9 +343,9 @@ impl ToolRegistry { }; if !handler.matches_kind(&invocation.payload) { - let message = format!("tool {display_name} invoked with incompatible payload"); + let message = format!("tool {tool_name} invoked with incompatible payload"); otel.tool_result_with_tags( - &display_name, + tool_name_flat.as_ref(), &call_id_owned, log_payload.as_ref(), Duration::ZERO, @@ -381,7 +382,7 @@ impl ToolRegistry { let started = Instant::now(); let result = otel .log_tool_result_with_tags( - &display_name, + tool_name_flat.as_ref(), &call_id_owned, log_payload.as_ref(), &metric_tags, @@ -573,7 +574,6 @@ impl ToolRegistryBuilder { } fn unsupported_tool_call_message(payload: &ToolPayload, tool_name: &ToolName) -> String { - let tool_name = tool_name.display(); match payload { ToolPayload::Custom { .. } => format!("unsupported custom tool call: {tool_name}"), _ => format!("unsupported call: {tool_name}"), @@ -646,6 +646,8 @@ async fn dispatch_after_tool_use_hook( let session = invocation.session.as_ref(); let turn = invocation.turn.as_ref(); let tool_input = HookToolInput::from(&invocation.payload); + let tool_name = &invocation.tool_name; + let hook_tool_name = flat_tool_name(tool_name); let hook_outcomes = session .hooks() .dispatch(HookPayload { @@ -657,7 +659,7 @@ async fn dispatch_after_tool_use_hook( event: HookEventAfterToolUse { turn_id: turn.sub_id.clone(), call_id: invocation.call_id.clone(), - tool_name: invocation.tool_name.display(), + tool_name: hook_tool_name.into_owned(), tool_kind: hook_tool_kind(&tool_input), tool_input, executed: dispatch.executed, @@ -688,7 +690,7 @@ async fn dispatch_after_tool_use_hook( HookResult::FailedContinue(error) => { warn!( call_id = %invocation.call_id, - tool_name = %invocation.tool_name.display(), + tool_name = %invocation.tool_name, hook_name = %hook_name, error = %error, "after_tool_use hook failed; continuing" @@ -697,7 +699,7 @@ async fn dispatch_after_tool_use_hook( HookResult::FailedAbort(error) => { warn!( call_id = %invocation.call_id, - tool_name = %invocation.tool_name.display(), + tool_name = %invocation.tool_name, hook_name = %hook_name, error = %error, "after_tool_use hook failed; aborting operation" diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 7f17285db9..4aa58552a4 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -17,6 +17,7 @@ use crate::sandboxing::ExecOptions; use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; use crate::shell::ShellType; +use crate::tools::flat_tool_name; use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::NetworkApprovalSpec; use crate::tools::runtimes::build_sandbox_command; @@ -227,7 +228,7 @@ impl ToolRuntime for ShellRuntime { mode: NetworkApprovalMode::Immediate, trigger: GuardianNetworkAccessTrigger { call_id: ctx.call_id.clone(), - tool_name: ctx.tool_name.clone(), + tool_name: flat_tool_name(&ctx.tool_name).into_owned(), command: req.command.clone(), cwd: req.cwd.clone(), sandbox_permissions: req.sandbox_permissions, diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 42f311bfcb..ae080e0388 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -14,6 +14,7 @@ use crate::sandboxing::ExecOptions; use crate::sandboxing::ExecServerEnvConfig; use crate::sandboxing::SandboxPermissions; use crate::shell::ShellType; +use crate::tools::flat_tool_name; use crate::tools::network_approval::NetworkApprovalMode; use crate::tools::network_approval::NetworkApprovalSpec; use crate::tools::runtimes::build_sandbox_command; @@ -233,7 +234,7 @@ impl<'a> ToolRuntime for UnifiedExecRunt mode: NetworkApprovalMode::Deferred, trigger: GuardianNetworkAccessTrigger { call_id: ctx.call_id.clone(), - tool_name: ctx.tool_name.clone(), + tool_name: flat_tool_name(&ctx.tool_name).into_owned(), command: req.command.clone(), cwd: req.cwd.clone(), sandbox_permissions: req.sandbox_permissions, diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index c17247beb4..6bb78632f7 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -27,6 +27,7 @@ use codex_sandboxing::SandboxTransformError; use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; +use codex_tools::ToolName; use codex_utils_absolute_path::AbsolutePathBuf; use futures::Future; use futures::future::BoxFuture; @@ -344,7 +345,7 @@ pub(crate) struct ToolCtx { pub session: Arc, pub turn: Arc, pub call_id: String, - pub tool_name: String, + pub tool_name: ToolName, } #[derive(Debug)] diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index a3e93f8436..a2fe8da970 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1,5 +1,6 @@ use crate::shell::Shell; use crate::shell::ShellType; +use crate::tools::flat_tool_name; use crate::tools::handlers::multi_agents_common::DEFAULT_WAIT_TIMEOUT_MS; use crate::tools::handlers::multi_agents_common::MAX_WAIT_TIMEOUT_MS; use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS; @@ -136,7 +137,7 @@ pub(crate) fn build_specs_with_discoverable_tools( .collect::>(); for unavailable_tool in unavailable_called_tools { - let tool_name = unavailable_tool.display(); + let tool_name = flat_tool_name(&unavailable_tool).into_owned(); if existing_spec_names.insert(tool_name.clone()) { let spec = codex_tools::ToolSpec::Function(ResponsesApiTool { name: tool_name.clone(), diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index ddad2cbf4e..1595db7b28 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -343,7 +343,7 @@ pub fn build_tool_registry_builder( if let Some(mcp_tools) = params.mcp_tools { let mut entries = mcp_tools.to_vec(); - entries.sort_by_key(|tool| tool.name.display()); + entries.sort_by(|a, b| a.name.cmp(&b.name)); let mut namespace_entries = BTreeMap::new(); for tool in entries { diff --git a/codex-rs/core/src/tools/tool_search_entry.rs b/codex-rs/core/src/tools/tool_search_entry.rs index a0e9a726b9..6a8ccd6402 100644 --- a/codex-rs/core/src/tools/tool_search_entry.rs +++ b/codex-rs/core/src/tools/tool_search_entry.rs @@ -1,3 +1,4 @@ +use crate::tools::flat_tool_name; use codex_mcp::ToolInfo; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_tools::LoadableToolSpec; @@ -22,7 +23,7 @@ pub(crate) fn build_tool_search_entries( let mut mcp_tools = mcp_tools .map(|tools| tools.iter().collect::>()) .unwrap_or_default(); - mcp_tools.sort_by_key(|info| info.canonical_tool_name().display()); + mcp_tools.sort_by_key(|info| info.canonical_tool_name()); for info in mcp_tools { match mcp_tool_search_entry(info) { Ok(entry) => entries.push(entry), @@ -94,8 +95,9 @@ fn dynamic_tool_search_entry(tool: &DynamicToolSpec) -> Result String { + let tool_name = info.canonical_tool_name(); let mut parts = vec![ - info.canonical_tool_name().display(), + flat_tool_name(&tool_name).into_owned(), info.callable_name.clone(), info.tool.name.to_string(), info.server_name.clone(), diff --git a/codex-rs/core/src/unavailable_tool.rs b/codex-rs/core/src/unavailable_tool.rs index aabf1f6058..9f71facead 100644 --- a/codex-rs/core/src/unavailable_tool.rs +++ b/codex-rs/core/src/unavailable_tool.rs @@ -1,6 +1,7 @@ use std::collections::BTreeMap; use std::collections::HashSet; +use crate::tools::flat_tool_name; use codex_protocol::models::ResponseItem; use codex_tools::ToolName; @@ -9,9 +10,9 @@ pub(crate) fn collect_unavailable_called_tools( exposed_tool_names: &HashSet, ) -> Vec { let mut unavailable_tools = BTreeMap::new(); - let exposed_display_names = exposed_tool_names + let exposed_flat_names = exposed_tool_names .iter() - .map(ToolName::display) + .map(flat_tool_name) .collect::>(); for item in input { @@ -29,13 +30,13 @@ pub(crate) fn collect_unavailable_called_tools( Some(namespace) => ToolName::namespaced(namespace.clone(), name.clone()), None => ToolName::plain(name.clone()), }; - let display_name = tool_name.display(); - if exposed_display_names.contains(&display_name) { + let tool_name_flat = flat_tool_name(&tool_name).into_owned(); + if exposed_flat_names.contains(tool_name_flat.as_str()) { continue; } unavailable_tools - .entry(display_name) + .entry(tool_name_flat) .or_insert_with(|| tool_name); } diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 4f85b1ddf7..73afca2da8 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -54,6 +54,7 @@ use codex_protocol::config_types::ShellEnvironmentPolicy; use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; use codex_protocol::protocol::ExecCommandSource; +use codex_tools::ToolName; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::approx_token_count; @@ -1040,7 +1041,7 @@ impl UnifiedExecProcessManager { session: context.session.clone(), turn: context.turn.clone(), call_id: context.call_id.clone(), - tool_name: "exec_command".to_string(), + tool_name: ToolName::plain("exec_command"), }; orchestrator .run( diff --git a/codex-rs/protocol/src/tool_name.rs b/codex-rs/protocol/src/tool_name.rs index d09bc1ce72..3d7219abe8 100644 --- a/codex-rs/protocol/src/tool_name.rs +++ b/codex-rs/protocol/src/tool_name.rs @@ -1,5 +1,6 @@ use serde::Deserialize; use serde::Serialize; +use std::cmp::Ordering; use std::fmt; /// Identifies a callable tool, preserving the namespace split when the model @@ -31,13 +32,6 @@ impl ToolName { namespace: Some(namespace.into()), } } - - pub fn display(&self) -> String { - match &self.namespace { - Some(namespace) => format!("{namespace}{}", self.name), - None => self.name.clone(), - } - } } impl fmt::Display for ToolName { @@ -49,6 +43,26 @@ impl fmt::Display for ToolName { } } +impl Ord for ToolName { + fn cmp(&self, other: &Self) -> Ordering { + let lhs = match &self.namespace { + Some(namespace) => (namespace.as_str(), Some(self.name.as_str())), + None => (self.name.as_str(), None), + }; + let rhs = match &other.namespace { + Some(namespace) => (namespace.as_str(), Some(other.name.as_str())), + None => (other.name.as_str(), None), + }; + lhs.cmp(&rhs) + } +} + +impl PartialOrd for ToolName { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + impl From for ToolName { fn from(name: String) -> Self { Self::plain(name) From 5f4d0ec343d807f6932e6bdc5785dc5a127ac409 Mon Sep 17 00:00:00 2001 From: Jiaming Zhang Date: Fri, 8 May 2026 12:36:02 -0700 Subject: [PATCH 09/27] [codex] request desktop attestation from app (#20619) ## Summary TL;DR: teaches `codex-rs` / app-server to request a desktop-provided attestation token and attach it as `x-oai-attestation` on the scoped ChatGPT Codex request paths. ![DeviceCheck attestation interface](https://raw.githubusercontent.com/openai/codex/dev/jm/devicecheck-diagram-assets/pr-assets/devicecheck-attestation-interface.png) ## Details This PR teaches the Codex app-server runtime how to request and attach an attestation token. It does not generate DeviceCheck tokens directly; instead, it relies on the connected desktop app to advertise that it can generate attestation and then asks that app for a fresh header value when needed. The flow is: 1. The Codex desktop app connects to app-server. 2. During `initialize`, the app can advertise that it supports `requestAttestation`. 3. Before app-server calls selected ChatGPT Codex endpoints, it sends the internal server request `attestation/generate` to the app. 4. app-server receives a pre-encoded header value back. 5. app-server forwards that value as `x-oai-attestation` on the scoped outbound requests. The code in this repo is mostly protocol and runtime plumbing: it adds the app-server request/response shape, introduces an attestation provider in core, wires that provider into Responses / compaction / realtime setup paths, and covers the intended scoping with tests. The signed macOS DeviceCheck generation remains owned by the desktop app PR. ## Related PR - Codex desktop app implementation: https://github.com/openai/openai/pull/878649 ## Validation
Tests run ```sh cargo test -p codex-app-server-protocol cargo test -p codex-core attestation --lib cargo test -p codex-app-server --lib attestation ``` Also ran: ```sh just fix -p codex-core just fix -p codex-app-server just fix -p codex-app-server-protocol just fmt just write-app-server-schema ```
E2E DeviceCheck validation First validated the signed desktop app boundary directly: launched a packaged signed `Codex.app`, sent `attestation/generate`, decoded the returned `v1.` attestation header, and validated the extracted DeviceCheck token with `personal/jm/verify_devicecheck_token.py` using bundle ID `com.openai.codex`. Apple returned `status_code: 200` and `is_ok: true`. Then ran the fuller app + app-server flow. The packaged `Codex.app` launched a current-branch app-server via `CODEX_CLI_PATH`, and a local MITM proxy intercepted outbound `chatgpt.com` traffic. The app-server requested `attestation/generate` from the real Electron app process, and the intercepted `/backend-api/codex/responses` traffic included `x-oai-attestation` on both routes: ```text GET /backend-api/codex/responses Upgrade: websocket x-oai-attestation: present POST /backend-api/codex/responses Upgrade: none x-oai-attestation: present ``` The captured header decoded to a DeviceCheck token that also validated with Apple for `com.openai.codex` (`status_code: 200`, `is_ok: true`, team `2DC432GLL2`).
--------- Co-authored-by: Codex --- .../analytics/src/analytics_client_tests.rs | 4 + codex-rs/app-server-client/src/lib.rs | 1 + codex-rs/app-server-client/src/remote.rs | 1 + .../json/AttestationGenerateParams.json | 5 + .../json/AttestationGenerateResponse.json | 14 ++ .../schema/json/ClientRequest.json | 5 + .../schema/json/ServerRequest.json | 28 +++ .../codex_app_server_protocol.schemas.json | 49 ++++ .../codex_app_server_protocol.v2.schemas.json | 5 + .../schema/json/v1/InitializeParams.json | 5 + .../typescript/InitializeCapabilities.ts | 4 + .../schema/typescript/ServerRequest.ts | 3 +- .../v2/AttestationGenerateParams.ts | 5 + .../v2/AttestationGenerateResponse.ts | 9 + .../schema/typescript/v2/index.ts | 2 + .../src/protocol/common.rs | 32 +++ .../app-server-protocol/src/protocol/v1.rs | 3 + .../src/protocol/v2/attestation.rs | 17 ++ .../src/protocol/v2/mod.rs | 2 + codex-rs/app-server-test-client/src/lib.rs | 1 + codex-rs/app-server/README.md | 4 + codex-rs/app-server/src/attestation.rs | 217 ++++++++++++++++++ codex-rs/app-server/src/lib.rs | 10 +- codex-rs/app-server/src/mcp_refresh.rs | 1 + codex-rs/app-server/src/message_processor.rs | 36 ++- codex-rs/app-server/src/outgoing_message.rs | 2 +- codex-rs/app-server/src/request_processors.rs | 1 + .../initialize_processor.rs | 21 +- .../request_processors/thread_processor.rs | 8 +- .../thread_processor_tests.rs | 99 +++++++- codex-rs/app-server/src/thread_state.rs | 39 +++- .../app-server/tests/suite/v2/attestation.rs | 194 ++++++++++++++++ .../tests/suite/v2/experimental_api.rs | 7 + .../app-server/tests/suite/v2/initialize.rs | 1 + codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../tests/suite/v2/thread_status.rs | 1 + codex-rs/core/src/attestation.rs | 26 +++ codex-rs/core/src/client.rs | 59 ++++- codex-rs/core/src/client_tests.rs | 115 ++++++++++ codex-rs/core/src/codex_delegate.rs | 1 + codex-rs/core/src/lib.rs | 4 + codex-rs/core/src/prompt_debug.rs | 1 + codex-rs/core/src/session/mod.rs | 4 + codex-rs/core/src/session/session.rs | 3 + codex-rs/core/src/session/tests.rs | 8 + .../core/src/session/tests/guardian_tests.rs | 1 + codex-rs/core/src/state/service.rs | 2 + codex-rs/core/src/thread_manager.rs | 6 + codex-rs/core/src/thread_manager_tests.rs | 11 + .../src/tools/handlers/multi_agents_tests.rs | 1 + codex-rs/core/tests/common/test_codex.rs | 1 + codex-rs/core/tests/responses_headers.rs | 3 + codex-rs/core/tests/suite/client.rs | 3 + .../core/tests/suite/client_websockets.rs | 1 + codex-rs/debug-client/src/client.rs | 1 + codex-rs/exec/src/lib.rs | 9 + codex-rs/mcp-server/src/message_processor.rs | 1 + codex-rs/memories/write/src/runtime.rs | 1 + codex-rs/model-provider-info/src/lib.rs | 3 +- codex-rs/model-provider/src/provider.rs | 12 + codex-rs/thread-manager-sample/src/main.rs | 1 + .../tui/src/app/app_server_event_targets.rs | 1 + codex-rs/tui/src/app/app_server_requests.rs | 7 + codex-rs/tui/src/app/side.rs | 1 + codex-rs/tui/src/chatwidget.rs | 1 + 65 files changed, 1086 insertions(+), 39 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/AttestationGenerateParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/AttestationGenerateResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AttestationGenerateParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/AttestationGenerateResponse.ts create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/attestation.rs create mode 100644 codex-rs/app-server/src/attestation.rs create mode 100644 codex-rs/app-server/tests/suite/v2/attestation.rs create mode 100644 codex-rs/core/src/attestation.rs diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 462b89c64a..2c2f724a95 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -640,6 +640,7 @@ fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact { }, capabilities: Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), }, @@ -1326,6 +1327,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, }), }, @@ -1473,6 +1475,7 @@ async fn compaction_event_ingests_custom_fact() { }, capabilities: Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), }, @@ -1586,6 +1589,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, }), }, diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 6dadec3b24..3b386ff3ce 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -374,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-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 fe3738c887..cb14a3a91a 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" diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 9844eac0b8..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": { @@ -1918,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 1747274799..0130e97653 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": { @@ -2620,6 +2639,11 @@ "array", "null" ] + }, + "requestAttestation": { + "default": false, + "description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.", + "type": "boolean" } }, "type": "object" @@ -5207,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": { 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 05b9165610..66180b11e1 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 @@ -6409,6 +6409,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/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/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/AttestationGenerateParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AttestationGenerateParams.ts new file mode 100644 index 0000000000..0e87e7d3e4 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AttestationGenerateParams.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 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/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 3cd919cb9f..7c431f9ec3 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"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 87716e0c9a..4e0ca3d1cb 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1312,6 +1312,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). @@ -1910,6 +1916,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(), @@ -1930,6 +1937,7 @@ mod tests { }, "capabilities": { "experimentalApi": true, + "requestAttestation": true, "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" @@ -1955,6 +1963,7 @@ mod tests { }, "capabilities": { "experimentalApi": true, + "requestAttestation": true, "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" @@ -1975,6 +1984,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(), @@ -2091,6 +2101,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 { 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/mod.rs b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs index 275e7ca45b..32c24bff1d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -2,6 +2,7 @@ mod shared; mod account; mod apps; +mod attestation; mod collaboration_mode; mod command_exec; mod config; @@ -26,6 +27,7 @@ mod windows_sandbox; pub use account::*; pub use apps::*; +pub use attestation::*; pub use collaboration_mode::*; pub use command_exec::*; pub use config::*; diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index e67f6e02f3..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() diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 01982d7ee5..9eed9ead72 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1337,6 +1337,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`. 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/lib.rs b/codex-rs/app-server/src/lib.rs index fcb2335b3c..af95800b24 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -74,6 +74,7 @@ use tracing_subscriber::util::SubscriberInitExt; mod analytics_utils; mod app_server_tracing; +mod attestation; mod bespoke_event_handling; mod command_exec; mod config; @@ -938,7 +939,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); diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index 8e1ccd3c0a..a327b4b125 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -187,6 +187,7 @@ mod tests { 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 7006c40343..062127d5a7 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -4,6 +4,7 @@ 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; @@ -34,6 +35,7 @@ use crate::request_processors::WindowsSandboxRequestProcessor; use crate::request_serialization::QueuedInitializedRequest; use crate::request_serialization::RequestSerializationQueueKey; use crate::request_serialization::RequestSerializationQueues; +use crate::thread_state::ConnectionCapabilities; use crate::thread_state::ThreadStateManager; use crate::transport::AppServerTransport; use crate::transport::RemoteControlHandle; @@ -82,6 +84,7 @@ use tokio::time::timeout; use tracing::Instrument; const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); + #[derive(Clone)] struct ExternalAuthRefreshBridge { outgoing: Arc, @@ -186,6 +189,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 +235,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,6 +290,7 @@ 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. @@ -293,13 +304,16 @@ impl MessageProcessor { 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 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)); @@ -620,9 +634,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 +741,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(()); diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index cbe196cd98..a373f635ba 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -267,7 +267,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, diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index cfd2589df1..5bfe97d305 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -473,6 +473,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; 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..c13ce4340f 100644 --- a/codex-rs/app-server/src/request_processors/initialize_processor.rs +++ b/codex-rs/app-server/src/request_processors/initialize_processor.rs @@ -65,15 +65,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, @@ -95,6 +97,7 @@ 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() { 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 615e37f2c9..015ae5daf6 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -2230,9 +2230,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; } 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..5068f19963 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 @@ -1115,7 +1115,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 +1160,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 +1209,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 +1259,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 +1276,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/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index dddbcf483b..82871fca8b 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -199,11 +199,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 +219,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 +367,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 +395,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/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/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/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index 165160468f..dcfd4e5499 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -158,6 +158,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/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/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 53c1eb0d7e..865c14e22a 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; @@ -170,6 +173,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 +319,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 +328,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 +342,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 +472,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 +498,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 +517,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 +655,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, @@ -779,7 +808,9 @@ impl ModelClient { auth_context: AuthRequestTelemetryContext, request_route_telemetry: RequestRouteTelemetry, ) -> std::result::Result { - let headers = self.build_websocket_headers(turn_state.as_ref(), turn_metadata_header); + let headers = self + .build_websocket_headers(turn_state.as_ref(), turn_metadata_header) + .await; let websocket_telemetry = ModelClientSession::build_websocket_telemetry( session_telemetry, auth_context, @@ -856,7 +887,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>, @@ -874,6 +905,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), @@ -922,7 +956,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, @@ -941,6 +975,9 @@ impl ModelClientSession { turn_metadata_header.as_ref(), ); headers.extend(self.client.build_responses_identity_headers()); + if let Some(header_value) = self.client.generate_attestation_header_for().await { + headers.insert(X_OAI_ATTESTATION_HEADER, header_value); + } headers }, compression, @@ -1217,7 +1254,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 options = self + .build_responses_options(turn_metadata_header, compression) + .await; let request = self.client.build_responses_request( &client_setup.api_provider, @@ -1324,7 +1363,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, diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 2ba65d7c45..b9d9172c83 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -7,13 +7,21 @@ use super::X_CODEX_PARENT_THREAD_ID_HEADER; use super::X_CODEX_TURN_METADATA_HEADER; use super::X_CODEX_WINDOW_ID_HEADER; use super::X_OPENAI_SUBAGENT_HEADER; +use crate::AttestationContext; +use crate::AttestationProvider; +use crate::GenerateAttestationFuture; use codex_api::ApiError; use codex_api::ResponseEvent; use codex_app_server_protocol::AuthMode; +use codex_login::AuthManager; +use codex_login::CodexAuth; use codex_model_provider::BearerAuthProvider; +use codex_model_provider_info::CHATGPT_CODEX_BASE_URL; +use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::WireApi; use codex_model_provider_info::create_oss_provider_with_base_url; use codex_otel::SessionTelemetry; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -36,6 +44,8 @@ use std::collections::VecDeque; use std::pin::Pin; use std::sync::Arc; use std::sync::Mutex; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; use std::task::Context; use std::task::Poll; use std::time::Duration; @@ -64,6 +74,7 @@ fn test_model_client(session_source: SessionSource) -> ModelClient { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*attestation_provider*/ None, ) } @@ -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..2503764904 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -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/lib.rs b/codex-rs/core/src/lib.rs index 0cdf0e2d46..57ac9b4a59 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -23,6 +23,7 @@ pub use codex_thread::CodexThread; pub use codex_thread::CodexThreadTurnContextOverrides; pub use codex_thread::ThreadConfigSnapshot; mod agent; +mod attestation; mod codex_delegate; mod command_canonicalization; mod commit_attribution; @@ -177,6 +178,9 @@ mod tasks; mod user_shell_command; pub mod util; +pub use attestation::AttestationContext; +pub use attestation::AttestationProvider; +pub use attestation::GenerateAttestationFuture; pub use client::ModelClient; pub use client::ModelClientSession; pub use client::X_CODEX_INSTALLATION_ID_HEADER; diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 0b9e334e07..688ce27508 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -53,6 +53,7 @@ pub async fn build_prompt_input( thread_store, state_db.clone(), installation_id, + /*attestation_provider*/ None, ); let thread = thread_manager.start_thread(config).await?; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 89c12aaf81..c6acf16f67 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -14,6 +14,7 @@ use crate::agent::Mailbox; use crate::agent::MailboxReceiver; use crate::agent::agent_status_from_event; use crate::agent::status::is_final; +use crate::attestation::AttestationProvider; use crate::build_available_skills; use crate::commit_attribution::commit_message_trailer_instruction; use crate::compact; @@ -412,6 +413,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) environment_selections: ResolvedTurnEnvironments, pub(crate) analytics_events_client: Option, pub(crate) thread_store: Arc, + pub(crate) attestation_provider: Option>, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -471,6 +473,7 @@ impl Codex { environment_selections, analytics_events_client, thread_store, + attestation_provider, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -656,6 +659,7 @@ impl Codex { analytics_events_client, thread_store, parent_rollout_thread_trace, + attestation_provider, ) .await .map_err(|e| { diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index f72a173c80..1a790314d5 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -370,6 +370,7 @@ impl Session { analytics_events_client: Option, thread_store: Arc, parent_rollout_thread_trace: ThreadTraceContext, + attestation_provider: Option>, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -852,6 +853,7 @@ impl Session { state_db: state_db_ctx.clone(), live_thread: live_thread_init.as_ref().cloned(), thread_store: Arc::clone(&thread_store), + attestation_provider: attestation_provider.clone(), model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), session_id, @@ -863,6 +865,7 @@ impl Session { config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), Self::build_model_client_beta_features_header(config.as_ref()), + attestation_provider, ), code_mode_service: crate::tools::code_mode::CodeModeService::new(), environment_manager, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index d3bbeb90c7..5090fc3f07 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -407,6 +407,7 @@ fn test_model_client_session() -> crate::client::ModelClientSession { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*attestation_provider*/ None, ) .new_session() } @@ -3733,6 +3734,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { /*state_db*/ None, )), codex_rollout_trace::ThreadTraceContext::disabled(), + /*attestation_provider*/ None, ) .await; @@ -3881,6 +3883,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), /*state_db*/ None, )), + attestation_provider: None, model_client: ModelClient::new( Some(auth_manager.clone()), thread_id.into(), @@ -3892,6 +3895,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), Session::build_model_client_beta_features_header(config.as_ref()), + /*attestation_provider*/ None, ), code_mode_service: crate::tools::code_mode::CodeModeService::new(), environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), @@ -4069,6 +4073,7 @@ async fn make_session_with_config_and_rx( /*state_db*/ None, )), codex_rollout_trace::ThreadTraceContext::disabled(), + /*attestation_provider*/ None, ) .await?; @@ -4178,6 +4183,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( ), )), codex_rollout_trace::ThreadTraceContext::disabled(), + /*attestation_provider*/ None, ) .await?; @@ -5596,6 +5602,7 @@ where codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), state_db, )), + attestation_provider: None, model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), thread_id.into(), @@ -5607,6 +5614,7 @@ where config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), Session::build_model_client_beta_features_header(config.as_ref()), + /*attestation_provider*/ None, ), code_mode_service: crate::tools::code_mode::CodeModeService::new(), environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 5c473ef1f9..37a5e6bb09 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -763,6 +763,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { }, analytics_events_client: None, thread_store, + attestation_provider: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 9cd9e97fbb..0dba931296 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::SkillsManager; use crate::agent::AgentControl; +use crate::attestation::AttestationProvider; use crate::client::ModelClient; use crate::config::StartedNetworkProxy; use crate::exec_policy::ExecPolicyManager; @@ -66,6 +67,7 @@ pub(crate) struct SessionServices { pub(crate) state_db: Option, pub(crate) live_thread: Option, pub(crate) thread_store: Arc, + pub(crate) attestation_provider: Option>, /// Session-scoped model client shared across turns. pub(crate) model_client: ModelClient, pub(crate) code_mode_service: CodeModeService, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 003f2786b0..de4114189b 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1,5 +1,6 @@ use crate::SkillsManager; use crate::agent::AgentControl; +use crate::attestation::AttestationProvider; use crate::codex_thread::CodexThread; use crate::config::Config; use crate::config::ThreadStoreConfig; @@ -248,6 +249,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, thread_store: Arc, + attestation_provider: Option>, session_source: SessionSource, installation_id: String, analytics_events_client: Option, @@ -291,6 +293,7 @@ impl ThreadManager { thread_store: Arc, state_db: Option, installation_id: String, + attestation_provider: Option>, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -317,6 +320,7 @@ impl ThreadManager { mcp_manager, skills_watcher, thread_store, + attestation_provider, auth_manager, session_source, installation_id, @@ -418,6 +422,7 @@ impl ThreadManager { mcp_manager, skills_watcher, thread_store, + attestation_provider: None, auth_manager, session_source: SessionSource::Exec, installation_id, @@ -1204,6 +1209,7 @@ impl ThreadManagerState { environment_selections, analytics_events_client: self.analytics_events_client.clone(), thread_store: Arc::clone(&self.thread_store), + attestation_provider: self.attestation_provider.clone(), }) .await?; let new_thread = self diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 683c8e6ab7..3bc5c77841 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -495,6 +495,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { thread_store_from_config(&config, /*state_db*/ None), /*state_db*/ None, TEST_INSTALLATION_ID.to_string(), + /*attestation_provider*/ None, ); let selected_cwd = AbsolutePathBuf::try_from(config.cwd.as_path().join("selected")).expect("absolute path"); @@ -611,6 +612,7 @@ async fn explicit_installation_id_skips_codex_home_file() { thread_store, state_db.clone(), installation_id.clone(), + /*attestation_provider*/ None, ); let thread = manager @@ -648,6 +650,7 @@ async fn resume_active_thread_from_rollout_returns_running_thread() { thread_store_from_config(&config, /*state_db*/ None), /*state_db*/ None, TEST_INSTALLATION_ID.to_string(), + /*attestation_provider*/ None, ); let source = manager @@ -703,6 +706,7 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() { thread_store_from_config(&config, /*state_db*/ None), /*state_db*/ None, TEST_INSTALLATION_ID.to_string(), + /*attestation_provider*/ None, ); let source = manager @@ -765,6 +769,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() { thread_store, state_db.clone(), TEST_INSTALLATION_ID.to_string(), + /*attestation_provider*/ None, ); let source = manager @@ -853,6 +858,7 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() { thread_store.clone(), state_db, TEST_INSTALLATION_ID.to_string(), + /*attestation_provider*/ None, ); let source = manager @@ -954,6 +960,7 @@ async fn new_uses_active_provider_for_model_refresh() { thread_store_from_config(&config, /*state_db*/ None), /*state_db*/ None, TEST_INSTALLATION_ID.to_string(), + /*attestation_provider*/ None, ); let _ = manager.list_models(RefreshStrategy::Online).await; @@ -1168,6 +1175,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor thread_store_from_config(&config, state_db.clone()), state_db.clone(), TEST_INSTALLATION_ID.to_string(), + /*attestation_provider*/ None, ); let source = manager @@ -1274,6 +1282,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { thread_store_from_config(&config, state_db.clone()), state_db.clone(), TEST_INSTALLATION_ID.to_string(), + /*attestation_provider*/ None, ); let source = manager @@ -1369,6 +1378,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ thread_store_from_config(&config, state_db.clone()), state_db.clone(), TEST_INSTALLATION_ID.to_string(), + /*attestation_provider*/ None, ); let source = manager @@ -1510,6 +1520,7 @@ async fn resumed_thread_keeps_paused_goal_paused() -> anyhow::Result<()> { thread_store_from_config(&config, state_db.clone()), state_db.clone(), TEST_INSTALLATION_ID.to_string(), + /*attestation_provider*/ None, ); let source = manager diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 43503be8c1..e7625fa44f 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -3167,6 +3167,7 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr thread_store_from_config(&config, state_db.clone()), state_db.clone(), "11111111-1111-4111-8111-111111111111".to_string(), + /*attestation_provider*/ None, ); let parent = manager diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index c348d76481..c326d84ebf 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -435,6 +435,7 @@ impl TestCodexBuilder { thread_store, state_db.clone(), installation_id, + /*attestation_provider*/ None, ); let thread_manager = Arc::new(thread_manager); let user_shell_override = self.user_shell_override.clone(); diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index af99790a1f..6f0429e644 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -109,6 +109,7 @@ async fn responses_stream_includes_subagent_header_on_review() { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*attestation_provider*/ None, ); let mut client_session = client.new_session(); @@ -236,6 +237,7 @@ async fn responses_stream_includes_subagent_header_on_other() { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*attestation_provider*/ None, ); let mut client_session = client.new_session(); @@ -352,6 +354,7 @@ async fn responses_respects_model_info_overrides_from_config() { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*attestation_provider*/ None, ); let mut client_session = client.new_session(); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index d23af820f5..83a7865d16 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -907,6 +907,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*attestation_provider*/ None, ); let mut client_session = client.new_session(); let mut prompt = Prompt::default(); @@ -1140,6 +1141,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { thread_store_from_config(&config, /*state_db*/ None), /*state_db*/ None, installation_id, + /*attestation_provider*/ None, ); let NewThread { thread: codex, .. } = thread_manager .start_thread(config.clone()) @@ -2327,6 +2329,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { /*enable_request_compression*/ false, /*include_timing_metrics*/ false, /*beta_features_header*/ None, + /*attestation_provider*/ None, ); let mut client_session = client.new_session(); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 21fbbd2f58..76bd35aa66 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -2035,6 +2035,7 @@ async fn websocket_harness_with_provider_options( /*enable_request_compression*/ false, runtime_metrics_enabled, /*beta_features_header*/ None, + /*attestation_provider*/ None, ); WebsocketTestHarness { diff --git a/codex-rs/debug-client/src/client.rs b/codex-rs/debug-client/src/client.rs index 2edabfac00..69eb474aaf 100644 --- a/codex-rs/debug-client/src/client.rs +++ b/codex-rs/debug-client/src/client.rs @@ -103,6 +103,7 @@ impl AppServerClient { }, capabilities: Some(InitializeCapabilities { experimental_api: true, + request_attestation: false, opt_out_notification_methods: None, }), }, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index ef33c614f4..e68c96d00b 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1606,6 +1606,15 @@ async fn handle_server_request( ) .await } + ServerRequest::AttestationGenerate { request_id, .. } => { + reject_server_request( + client, + request_id, + &method, + "attestation generation is not supported in exec mode".to_string(), + ) + .await + } ServerRequest::ApplyPatchApproval { request_id, params } => { reject_server_request( client, diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index d64fc43b1b..f963747e87 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -72,6 +72,7 @@ impl MessageProcessor { thread_store_from_config(config.as_ref(), state_db.clone()), state_db.clone(), installation_id, + /*attestation_provider*/ None, )); Self { outgoing, diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 65e3fa1cc1..18b1bd6db1 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -183,6 +183,7 @@ impl MemoryStartupContext { config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), /*beta_features_header*/ None, + /*attestation_provider*/ None, ); let mut client_session = model_client.new_session(); diff --git a/codex-rs/model-provider-info/src/lib.rs b/codex-rs/model-provider-info/src/lib.rs index 0fb8be4746..6fca7e6a1f 100644 --- a/codex-rs/model-provider-info/src/lib.rs +++ b/codex-rs/model-provider-info/src/lib.rs @@ -34,6 +34,7 @@ const MAX_REQUEST_MAX_RETRIES: u64 = 100; const OPENAI_PROVIDER_NAME: &str = "OpenAI"; pub const OPENAI_PROVIDER_ID: &str = "openai"; +pub const CHATGPT_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex"; const AMAZON_BEDROCK_PROVIDER_NAME: &str = "Amazon Bedrock"; pub const AMAZON_BEDROCK_PROVIDER_ID: &str = "amazon-bedrock"; pub const AMAZON_BEDROCK_DEFAULT_BASE_URL: &str = @@ -234,7 +235,7 @@ impl ModelProviderInfo { auth_mode, Some(AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity) ) { - "https://chatgpt.com/backend-api/codex" + CHATGPT_CODEX_BASE_URL } else { "https://api.openai.com/v1" }; diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index 0c5e8e0ffe..8e1d37b29d 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -85,6 +85,11 @@ pub trait ModelProvider: fmt::Debug + Send + Sync { ProviderCapabilities::default() } + /// Returns whether requests made through this provider should include attestation. + fn supports_attestation(&self) -> bool { + false + } + /// Returns the provider-scoped auth manager, when this provider uses one. /// /// TODO(celia-oai): Make auth manager access internal to this crate so callers @@ -167,6 +172,13 @@ impl ModelProvider for ConfiguredModelProvider { self.auth_manager.clone() } + fn supports_attestation(&self) -> bool { + self.auth_manager + .as_ref() + .and_then(|auth_manager| auth_manager.auth_cached()) + .is_some_and(|auth| auth.is_chatgpt_auth()) + } + async fn auth(&self) -> Option { match self.auth_manager.as_ref() { Some(auth_manager) => auth_manager.auth().await, diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index fa653ce459..68a95bd2d9 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -126,6 +126,7 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { Arc::clone(&thread_store), state_db, installation_id, + /*attestation_provider*/ None, ); let NewThread { diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index 382a82a19f..d535bf8e3d 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -25,6 +25,7 @@ pub(super) fn server_request_thread_id(request: &ServerRequest) -> Option None, } diff --git a/codex-rs/tui/src/app/app_server_requests.rs b/codex-rs/tui/src/app/app_server_requests.rs index dce87f367e..1843333023 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -134,6 +134,12 @@ impl PendingAppServerRequests { }) } ServerRequest::ChatgptAuthTokensRefresh { .. } => None, + ServerRequest::AttestationGenerate { request_id, .. } => { + Some(UnsupportedAppServerRequest { + request_id: request_id.clone(), + message: "Attestation generation is not available in TUI.".to_string(), + }) + } ServerRequest::ApplyPatchApproval { request_id, .. } => { Some(UnsupportedAppServerRequest { request_id: request_id.clone(), @@ -332,6 +338,7 @@ impl PendingAppServerRequests { .any(|pending_request_id| pending_request_id == request_id), ServerRequest::DynamicToolCall { .. } | ServerRequest::ChatgptAuthTokensRefresh { .. } + | ServerRequest::AttestationGenerate { .. } | ServerRequest::ApplyPatchApproval { .. } | ServerRequest::ExecCommandApproval { .. } => true, } diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index 59f3d71991..d3ea62da70 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -92,6 +92,7 @@ impl SideParentStatus { | ServerRequest::ApplyPatchApproval { .. } | ServerRequest::ExecCommandApproval { .. } => Some(SideParentStatus::NeedsApproval), ServerRequest::DynamicToolCall { .. } + | ServerRequest::AttestationGenerate { .. } | ServerRequest::ChatgptAuthTokensRefresh { .. } => None, } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0e601e02c9..85a9c3a8f3 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -6207,6 +6207,7 @@ impl ChatWidget { self.on_request_user_input(params); } ServerRequest::DynamicToolCall { .. } + | ServerRequest::AttestationGenerate { .. } | ServerRequest::ChatgptAuthTokensRefresh { .. } | ServerRequest::ApplyPatchApproval { .. } | ServerRequest::ExecCommandApproval { .. } => { From cf941ede15a5528930b62f2d075dd39887773e0c Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Fri, 8 May 2026 22:37:10 +0300 Subject: [PATCH 10/27] Revert "Publish Python runtime wheels on release" (#21810) Reverts openai/codex#21784 --- .github/workflows/rust-release-windows.yml | 41 -------- .github/workflows/rust-release.yml | 114 --------------------- sdk/python/README.md | 2 +- 3 files changed, 1 insertion(+), 156 deletions(-) diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 24cdebe454..2eb7ef2a47 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -220,47 +220,6 @@ 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 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" - python -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.yml b/.github/workflows/rust-release.yml index f958153f9c..d05340a020 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -399,61 +399,6 @@ 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 pip install build - - stage_dir="${RUNNER_TEMP}/openai-codex-cli-bin-${{ matrix.target }}" - wheel_dir="${GITHUB_WORKSPACE}/python-runtime-dist/${{ matrix.target }}" - resource_args=() - 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. - resource_args+=( - --resource-binary - "${GITHUB_WORKSPACE}/codex-rs/target/${{ matrix.target }}/release/bwrap" - ) - fi - python3 "${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" \ - "${resource_args[@]}" - python3 -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: | @@ -533,7 +478,6 @@ 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 @@ -610,22 +554,6 @@ 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: @@ -859,48 +787,6 @@ 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/sdk/python/README.md b/sdk/python/README.md index 031471b811..149420ad95 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -91,7 +91,7 @@ This supports the CI release flow: - run `generate-types` before packaging - stage `openai-codex-app-server-sdk` once with an exact `openai-codex-cli-bin==...` dependency - stage `openai-codex-cli-bin` on each supported platform runner with the same pinned runtime version -- build and publish `openai-codex-cli-bin` as platform wheels only through PyPI trusted publishing; do not publish an sdist +- build and publish `openai-codex-cli-bin` as platform wheels only; do not publish an sdist ## Compatibility and versioning From e783341b705721728a8fa422416c10c3a09c7716 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 8 May 2026 13:00:57 -0700 Subject: [PATCH 11/27] [codex] Delete function-style apply_patch (#21651) ## Why `apply_patch` is now a freeform/custom tool. Keeping the old JSON/function-style registration and parsing path left another way for models and tests to invoke `apply_patch`, which made the tool surface harder to reason about. ## What changed - Removed the `ApplyPatchToolType::Function` variant, JSON `apply_patch` spec, and handler support for function payloads. - Kept `apply_patch_tool_type = freeform` as the supported model metadata path, including Bedrock catalog metadata. - Migrated `apply_patch` tests and SSE fixtures to custom/freeform tool calls. ## Verification - `cargo test -p codex-tools -p codex-protocol -p codex-model-provider` - `cargo test -p codex-core tools::handlers::apply_patch --lib` - `cargo test -p codex-core --test all apply_patch_tool_executes_and_emits_patch_events` - `cargo test -p codex-core --test all apply_patch_reports_parse_diagnostics` - `cargo test -p codex-exec test_apply_patch_tool` - `just fix -p codex-core` - `just fix -p codex-tools -p codex-protocol -p codex-model-provider -p codex-exec` --- .../core/src/tools/handlers/apply_patch.rs | 56 ++-------- .../src/tools/handlers/apply_patch_spec.rs | 103 ------------------ .../tools/handlers/apply_patch_spec_tests.rs | 26 ----- .../src/tools/handlers/apply_patch_tests.rs | 40 +------ codex-rs/core/src/tools/spec_plan.rs | 8 +- codex-rs/core/tests/common/responses.rs | 21 +--- codex-rs/core/tests/common/test_codex.rs | 4 +- codex-rs/core/tests/suite/apply_patch_cli.rs | 44 ++------ codex-rs/core/tests/suite/approvals.rs | 91 ++++++++-------- codex-rs/core/tests/suite/codex_delegate.rs | 4 +- codex-rs/core/tests/suite/hooks.rs | 18 +-- .../tests/suite/request_permissions_tool.rs | 21 +++- .../core/tests/suite/shell_serialization.rs | 15 +-- codex-rs/core/tests/suite/tool_harness.rs | 36 ++++-- codex-rs/core/tests/suite/tools.rs | 11 +- codex-rs/exec/tests/suite/apply_patch.rs | 3 +- .../src/amazon_bedrock/catalog.rs | 2 +- codex-rs/protocol/src/openai_models.rs | 1 - codex-rs/tools/src/tool_config.rs | 9 +- codex-rs/tools/src/tool_config_tests.rs | 4 +- 20 files changed, 143 insertions(+), 374 deletions(-) diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 66b04f8e73..452bcb8d85 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -21,10 +21,7 @@ use crate::tools::context::ToolPayload; use crate::tools::events::ToolEmitter; use crate::tools::events::ToolEventCtx; use crate::tools::handlers::apply_granted_turn_permissions; -use crate::tools::handlers::apply_patch_spec::ApplyPatchToolArgs; use crate::tools::handlers::apply_patch_spec::create_apply_patch_freeform_tool; -use crate::tools::handlers::apply_patch_spec::create_apply_patch_json_tool; -use crate::tools::handlers::parse_arguments; use crate::tools::hook_names::HookToolName; use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::registry::PostToolUsePayload; @@ -43,7 +40,6 @@ use codex_exec_server::ExecutorFileSystem; use codex_features::Feature; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions; -use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::FileChange; use codex_protocol::protocol::PatchApplyUpdatedEvent; @@ -56,25 +52,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; const APPLY_PATCH_ARGUMENT_DIFF_BUFFER_INTERVAL: Duration = Duration::from_millis(500); -pub struct ApplyPatchHandler { - options: ApplyPatchToolType, -} - -impl Default for ApplyPatchHandler { - fn default() -> Self { - Self { - options: ApplyPatchToolType::Freeform, - } - } -} - -impl ApplyPatchHandler { - pub(crate) fn new(apply_patch_tool_type: ApplyPatchToolType) -> Self { - Self { - options: apply_patch_tool_type, - } - } -} +pub struct ApplyPatchHandler; #[derive(Default)] struct ApplyPatchArgumentDiffConsumer { @@ -264,15 +242,8 @@ fn write_permissions_for_paths( } /// Extracts the raw patch text used as the command-shaped hook input for apply_patch. -/// -/// The apply_patch tool can arrive as the older JSON/function shape or as a -/// freeform custom tool call. Both represent the same file edit operation, so -/// hooks see the raw patch body in `tool_input.command` either way. fn apply_patch_payload_command(payload: &ToolPayload) -> Option { match payload { - ToolPayload::Function { arguments } => parse_arguments::(arguments) - .ok() - .map(|args| args.input), ToolPayload::Custom { input } => Some(input.clone()), _ => None, } @@ -320,10 +291,7 @@ impl ToolHandler for ApplyPatchHandler { } fn spec(&self) -> Option { - Some(match self.options { - ApplyPatchToolType::Freeform => create_apply_patch_freeform_tool(), - ApplyPatchToolType::Function => create_apply_patch_json_tool(), - }) + Some(create_apply_patch_freeform_tool()) } fn kind(&self) -> ToolKind { @@ -331,10 +299,7 @@ impl ToolHandler for ApplyPatchHandler { } fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!( - payload, - ToolPayload::Function { .. } | ToolPayload::Custom { .. } - ) + matches!(payload, ToolPayload::Custom { .. }) } async fn is_mutating(&self, _invocation: &ToolInvocation) -> bool { @@ -380,17 +345,10 @@ impl ToolHandler for ApplyPatchHandler { .. } = invocation; - let patch_input = match payload { - ToolPayload::Function { arguments } => { - let args: ApplyPatchToolArgs = parse_arguments(&arguments)?; - args.input - } - ToolPayload::Custom { input } => input, - _ => { - return Err(FunctionCallError::RespondToModel( - "apply_patch handler received unsupported payload".to_string(), - )); - } + let ToolPayload::Custom { input: patch_input } = payload else { + return Err(FunctionCallError::RespondToModel( + "apply_patch handler received unsupported payload".to_string(), + )); }; // Re-parse and verify the patch so we can compute changes and approval. diff --git a/codex-rs/core/src/tools/handlers/apply_patch_spec.rs b/codex-rs/core/src/tools/handlers/apply_patch_spec.rs index 93a3ce4aac..fa1ffcb8c0 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch_spec.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch_spec.rs @@ -1,89 +1,9 @@ use codex_tools::FreeformTool; use codex_tools::FreeformToolFormat; -use codex_tools::JsonSchema; -use codex_tools::ResponsesApiTool; use codex_tools::ToolSpec; -use serde::Deserialize; -use serde::Serialize; -use std::collections::BTreeMap; const APPLY_PATCH_LARK_GRAMMAR: &str = include_str!("apply_patch.lark"); -const APPLY_PATCH_JSON_TOOL_DESCRIPTION: &str = r#"Use the `apply_patch` tool to edit files. -Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope: - -*** Begin Patch -[ one or more file sections ] -*** End Patch - -Within that envelope, you get a sequence of file operations. -You MUST include a header to specify the action you are taking. -Each operation starts with one of three headers: - -*** Add File: - create a new file. Every following line is a + line (the initial contents). -*** Delete File: - remove an existing file. Nothing follows. -*** Update File: - patch an existing file in place (optionally with a rename). - -May be immediately followed by *** Move to: if you want to rename the file. -Then one or more “hunks”, each introduced by @@ (optionally followed by a hunk header). -Within a hunk each line starts with: - -For instructions on [context_before] and [context_after]: -- By default, show 3 lines of code immediately above and 3 lines immediately below each change. If a change is within 3 lines of a previous change, do NOT duplicate the first change’s [context_after] lines in the second change’s [context_before] lines. -- If 3 lines of context is insufficient to uniquely identify the snippet of code within the file, use the @@ operator to indicate the class or function to which the snippet belongs. For instance, we might have: -@@ class BaseClass -[3 lines of pre-context] -- [old_code] -+ [new_code] -[3 lines of post-context] - -- If a code block is repeated so many times in a class or function such that even a single `@@` statement and 3 lines of context cannot uniquely identify the snippet of code, you can use multiple `@@` statements to jump to the right context. For instance: - -@@ class BaseClass -@@ def method(): -[3 lines of pre-context] -- [old_code] -+ [new_code] -[3 lines of post-context] - -The full grammar definition is below: -Patch := Begin { FileOp } End -Begin := "*** Begin Patch" NEWLINE -End := "*** End Patch" NEWLINE -FileOp := AddFile | DeleteFile | UpdateFile -AddFile := "*** Add File: " path NEWLINE { "+" line NEWLINE } -DeleteFile := "*** Delete File: " path NEWLINE -UpdateFile := "*** Update File: " path NEWLINE [ MoveTo ] { Hunk } -MoveTo := "*** Move to: " newPath NEWLINE -Hunk := "@@" [ header ] NEWLINE { HunkLine } [ "*** End of File" NEWLINE ] -HunkLine := (" " | "-" | "+") text NEWLINE - -A full patch can combine several operations: - -*** Begin Patch -*** Add File: hello.txt -+Hello world -*** Update File: src/app.py -*** Move to: src/main.py -@@ def greet(): --print("Hi") -+print("Hello, world!") -*** Delete File: obsolete.txt -*** End Patch - -It is important to remember: - -- You must include a header with your intended action (Add/Delete/Update) -- You must prefix new lines with `+` even when creating a new file -- File references can only be relative, NEVER ABSOLUTE. -"#; - -/// TODO(dylan): deprecate once we get rid of json tool -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ApplyPatchToolArgs { - pub input: String, -} - /// Returns a custom tool that can be used to edit files. Well-suited for GPT-5 models /// https://platform.openai.com/docs/guides/function-calling#custom-tools pub fn create_apply_patch_freeform_tool() -> ToolSpec { @@ -98,29 +18,6 @@ pub fn create_apply_patch_freeform_tool() -> ToolSpec { }) } -/// Returns a json tool that can be used to edit files. Should only be used with gpt-oss models -pub fn create_apply_patch_json_tool() -> ToolSpec { - let properties = BTreeMap::from([( - "input".to_string(), - JsonSchema::string(Some( - "The entire contents of the apply_patch command".to_string(), - )), - )]); - - ToolSpec::Function(ResponsesApiTool { - name: "apply_patch".to_string(), - description: APPLY_PATCH_JSON_TOOL_DESCRIPTION.to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - properties, - Some(vec!["input".to_string()]), - Some(false.into()), - ), - output_schema: None, - }) -} - #[cfg(test)] #[path = "apply_patch_spec_tests.rs"] mod tests; diff --git a/codex-rs/core/src/tools/handlers/apply_patch_spec_tests.rs b/codex-rs/core/src/tools/handlers/apply_patch_spec_tests.rs index beda5cc916..28bd57a991 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch_spec_tests.rs @@ -1,7 +1,5 @@ use super::*; -use codex_tools::JsonSchema; use pretty_assertions::assert_eq; -use std::collections::BTreeMap; #[test] fn create_apply_patch_freeform_tool_matches_expected_spec() { @@ -20,27 +18,3 @@ fn create_apply_patch_freeform_tool_matches_expected_spec() { }) ); } - -#[test] -fn create_apply_patch_json_tool_matches_expected_spec() { - assert_eq!( - create_apply_patch_json_tool(), - ToolSpec::Function(ResponsesApiTool { - name: "apply_patch".to_string(), - description: APPLY_PATCH_JSON_TOOL_DESCRIPTION.to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - BTreeMap::from([( - "input".to_string(), - JsonSchema::string(Some( - "The entire contents of the apply_patch command".to_string(), - ),), - )]), - Some(vec!["input".to_string()]), - Some(false.into()) - ), - output_schema: None, - }) - ); -} diff --git a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs index c0d4d17f32..dfa642ade1 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs @@ -42,24 +42,6 @@ async fn invocation_for_payload(payload: ToolPayload) -> ToolInvocation { } } -#[tokio::test] -async fn pre_tool_use_payload_uses_json_patch_input() { - let patch = sample_patch(); - let payload = ToolPayload::Function { - arguments: json!({ "input": patch }).to_string(), - }; - let invocation = invocation_for_payload(payload).await; - let handler = ApplyPatchHandler::default(); - - assert_eq!( - handler.pre_tool_use_payload(&invocation), - Some(PreToolUsePayload { - tool_name: HookToolName::apply_patch(), - tool_input: json!({ "command": patch }), - }) - ); -} - #[tokio::test] async fn pre_tool_use_payload_uses_freeform_patch_input() { let patch = sample_patch(); @@ -67,7 +49,7 @@ async fn pre_tool_use_payload_uses_freeform_patch_input() { input: patch.to_string(), }; let invocation = invocation_for_payload(payload).await; - let handler = ApplyPatchHandler::default(); + let handler = ApplyPatchHandler; assert_eq!( handler.pre_tool_use_payload(&invocation), @@ -86,7 +68,7 @@ async fn post_tool_use_payload_uses_patch_input_and_tool_output() { }; let invocation = invocation_for_payload(payload).await; let output = ApplyPatchToolOutput::from_text("Success. Updated files.".to_string()); - let handler = ApplyPatchHandler::default(); + let handler = ApplyPatchHandler; assert_eq!( handler.post_tool_use_payload(&invocation, &output), @@ -99,24 +81,6 @@ async fn post_tool_use_payload_uses_patch_input_and_tool_output() { ); } -#[test] -fn diff_consumer_does_not_stream_json_tool_call_arguments() { - let mut consumer = ApplyPatchArgumentDiffConsumer::default(); - assert!( - consumer - .push_delta("call-1".to_string(), r#"{"input":"*** Begin Patch\n"#) - .is_none() - ); - assert!( - consumer - .push_delta( - "call-1".to_string(), - r#"*** Add File: hello.txt\n+hello\n*** End Patch\n"}"# - ) - .is_none() - ); -} - #[test] fn diff_consumer_streams_apply_patch_changes() { let mut consumer = ApplyPatchArgumentDiffConsumer::default(); diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 1595db7b28..4361cfe4fd 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -257,12 +257,8 @@ pub fn build_tool_registry_builder( ))); } - if config.environment_mode.has_environment() - && let Some(apply_patch_tool_type) = &config.apply_patch_tool_type - { - builder.register_handler(Arc::new(ApplyPatchHandler::new( - apply_patch_tool_type.clone(), - ))); + if config.environment_mode.has_environment() && config.apply_patch_tool_type.is_some() { + builder.register_handler(Arc::new(ApplyPatchHandler)); } if config diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 93472e72bb..eab90c5aba 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -890,7 +890,6 @@ pub fn ev_apply_patch_call( ) -> Value { match output_type { ApplyPatchModelOutput::Freeform => ev_apply_patch_custom_tool_call(call_id, patch), - ApplyPatchModelOutput::Function => ev_apply_patch_function_call(call_id, patch), ApplyPatchModelOutput::Shell => ev_apply_patch_shell_call(call_id, patch), ApplyPatchModelOutput::ShellViaHeredoc => { ev_apply_patch_shell_call_via_heredoc(call_id, patch) @@ -903,7 +902,7 @@ pub fn ev_apply_patch_call( /// Convenience: SSE event for an `apply_patch` custom tool call with raw patch /// text. This mirrors the payload produced by the Responses API when the model -/// invokes `apply_patch` directly (before we convert it to a function call). +/// invokes `apply_patch` directly. pub fn ev_apply_patch_custom_tool_call(call_id: &str, patch: &str) -> Value { serde_json::json!({ "type": "response.output_item.done", @@ -916,24 +915,6 @@ pub fn ev_apply_patch_custom_tool_call(call_id: &str, patch: &str) -> Value { }) } -/// Convenience: SSE event for an `apply_patch` function call. The Responses API -/// wraps the patch content in a JSON string under the `input` key; we recreate -/// the same structure so downstream code exercises the full parsing path. -pub fn ev_apply_patch_function_call(call_id: &str, patch: &str) -> Value { - let arguments = serde_json::json!({ "input": patch }); - let arguments = serde_json::to_string(&arguments).expect("serialize apply_patch arguments"); - - serde_json::json!({ - "type": "response.output_item.done", - "item": { - "type": "function_call", - "name": "apply_patch", - "arguments": arguments, - "call_id": call_id - } - }) -} - pub fn ev_shell_command_call(call_id: &str, command: &str) -> Value { let args = serde_json::json!({ "command": command }); ev_shell_command_call_with_args(call_id, &args) diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index c326d84ebf..bcc93d7d09 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -188,7 +188,6 @@ fn docker_command_capture_stdout(args: [&str; N]) -> Result { Box::pin(self.custom_tool_call_output(call_id)).await } - ApplyPatchModelOutput::Function - | ApplyPatchModelOutput::Shell + ApplyPatchModelOutput::Shell | ApplyPatchModelOutput::ShellViaHeredoc | ApplyPatchModelOutput::ShellCommandViaHeredoc => { Box::pin(self.function_call_stdout(call_id)).await diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index bc51fdd460..3f85d31c7c 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -24,7 +24,6 @@ use codex_protocol::user_input::UserInput; #[cfg(target_os = "linux")] use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0; use core_test_support::assert_regex_match; -use core_test_support::responses::ev_apply_patch_function_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; @@ -172,14 +171,14 @@ async fn apply_patch_cli_uses_codex_self_exe_with_linux_sandbox_helper_alias() - call_id, patch, "done", - ApplyPatchModelOutput::Function, + ApplyPatchModelOutput::Freeform, ) .await; harness.submit("please apply helper alias patch").await?; let out = harness - .apply_patch_output(call_id, ApplyPatchModelOutput::Function) + .apply_patch_output(call_id, ApplyPatchModelOutput::Freeform) .await; assert_regex_match( r"(?s)^Exit code: 0.*Success\. Updated the following files:\nA helper-alias\.txt\n?$", @@ -192,7 +191,6 @@ async fn apply_patch_cli_uses_codex_self_exe_with_linux_sandbox_helper_alias() - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] async fn apply_patch_cli_multiple_operations_integration( @@ -237,7 +235,6 @@ D delete.txt #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] @@ -265,7 +262,6 @@ async fn apply_patch_cli_multiple_chunks(model_output: ApplyPatchModelOutput) -> #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] @@ -294,7 +290,6 @@ async fn apply_patch_cli_moves_file_to_new_directory( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] @@ -323,7 +318,6 @@ async fn apply_patch_cli_updates_file_appends_trailing_newline( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] @@ -353,7 +347,6 @@ async fn apply_patch_cli_insert_only_hunk_modifies_file( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] @@ -385,7 +378,6 @@ async fn apply_patch_cli_move_overwrites_existing_destination( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] @@ -425,7 +417,6 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] @@ -453,7 +444,6 @@ async fn apply_patch_cli_add_overwrites_existing_file( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] @@ -485,7 +475,6 @@ async fn apply_patch_cli_rejects_invalid_hunk_header( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] @@ -521,7 +510,6 @@ async fn apply_patch_cli_reports_missing_context( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] @@ -557,7 +545,6 @@ async fn apply_patch_cli_reports_missing_target_file( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] @@ -594,7 +581,6 @@ async fn apply_patch_cli_delete_missing_file_reports_error( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] @@ -619,7 +605,6 @@ async fn apply_patch_cli_rejects_empty_patch(model_output: ApplyPatchModelOutput #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] @@ -646,7 +631,6 @@ async fn apply_patch_cli_delete_directory_reports_verification_error( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] @@ -693,7 +677,6 @@ async fn apply_patch_cli_rejects_path_traversal_outside_workspace( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] @@ -743,7 +726,6 @@ async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] @@ -1171,7 +1153,7 @@ async fn apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nest call_id, patch, "updated repo-relative path", - ApplyPatchModelOutput::Function, + ApplyPatchModelOutput::Freeform, ) .await; @@ -1260,7 +1242,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] -async fn apply_patch_function_accepts_lenient_heredoc_wrapped_patch( +async fn apply_patch_shell_accepts_lenient_heredoc_wrapped_patch( model_output: ApplyPatchModelOutput, ) -> Result<()> { skip_if_no_network!(Ok(())); @@ -1281,7 +1263,6 @@ async fn apply_patch_function_accepts_lenient_heredoc_wrapped_patch( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] @@ -1303,7 +1284,6 @@ async fn apply_patch_cli_end_of_file_anchor(model_output: ApplyPatchModelOutput) #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] @@ -1340,7 +1320,6 @@ async fn apply_patch_cli_missing_second_chunk_context_rejected( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] @@ -1394,12 +1373,12 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()> let s1 = sse(vec![ ev_response_created("resp-1"), - ev_apply_patch_function_call(call1, patch1), + ev_apply_patch_custom_tool_call(call1, patch1), ev_completed("resp-1"), ]); let s2 = sse(vec![ ev_response_created("resp-2"), - ev_apply_patch_function_call(call2, patch2), + ev_apply_patch_custom_tool_call(call2, patch2), ev_completed("resp-2"), ]); let s3 = sse(vec![ @@ -1446,12 +1425,12 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result let responses = vec![ sse(vec![ ev_response_created("resp-1"), - ev_apply_patch_function_call(call_success, patch_success), + ev_apply_patch_custom_tool_call(call_success, patch_success), ev_completed("resp-1"), ]), sse(vec![ ev_response_created("resp-2"), - ev_apply_patch_function_call(call_failure, patch_failure), + ev_apply_patch_custom_tool_call(call_failure, patch_failure), ev_completed("resp-2"), ]), sse(vec![ @@ -1488,7 +1467,7 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result "diff should include contents from successful patch: {diff}" ); - let failure_out = harness.function_call_stdout(call_failure).await; + let failure_out = harness.custom_tool_call_output(call_failure).await; assert!( failure_out.contains("apply_patch verification failed"), "expected verification failure output: {failure_out}" @@ -1529,12 +1508,12 @@ async fn apply_patch_clears_aggregated_diff_after_inexact_delta() -> Result<()> let responses = vec![ sse(vec![ ev_response_created("resp-1"), - ev_apply_patch_function_call(call_success, patch_success), + ev_apply_patch_custom_tool_call(call_success, patch_success), ev_completed("resp-1"), ]), sse(vec![ ev_response_created("resp-2"), - ev_apply_patch_function_call(call_inexact, patch_inexact), + ev_apply_patch_custom_tool_call(call_inexact, patch_inexact), ev_completed("resp-2"), ]), sse(vec![ @@ -1573,7 +1552,6 @@ async fn apply_patch_clears_aggregated_diff_after_inexact_delta() -> Result<()> #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] #[test_case(ApplyPatchModelOutput::ShellCommandViaHeredoc)] diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index 2538c850e3..10bbb852eb 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -20,7 +20,7 @@ use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::managed_network_requirements_loader; -use core_test_support::responses::ev_apply_patch_function_call; +use core_test_support::responses::ev_apply_patch_custom_tool_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; @@ -108,7 +108,7 @@ enum ActionKind { command: &'static str, justification: Option<&'static str>, }, - ApplyPatchFunction { + ApplyPatchFreeform { target: TargetPath, content: &'static str, }, @@ -131,7 +131,7 @@ impl ActionKind { | ActionKind::RunCommand { .. } | ActionKind::RunCommandWithPrefixRule { .. } | ActionKind::RunUnifiedExecCommand { .. } - | ActionKind::ApplyPatchFunction { .. } + | ActionKind::ApplyPatchFreeform { .. } | ActionKind::ApplyPatchShell { .. } => None, } } @@ -264,11 +264,11 @@ impl ActionKind { )?; Ok((event, Some(command.to_string()))) } - ActionKind::ApplyPatchFunction { target, content } => { + ActionKind::ApplyPatchFreeform { target, content } => { let (path, patch_path) = target.resolve_for_patch(test); let _ = fs::remove_file(&path); let patch = build_add_file_patch(&patch_path, content); - Ok((ev_apply_patch_function_call(call_id, &patch), None)) + Ok((ev_apply_patch_custom_tool_call(call_id, &patch), None)) } ActionKind::ApplyPatchShell { target, content } => { let (path, patch_path) = target.resolve_for_patch(test); @@ -1342,46 +1342,46 @@ fn scenarios() -> Vec { }, }, ScenarioSpec { - name: "apply_patch_function_auto_inside_workspace", + name: "apply_patch_freeform_auto_inside_workspace", approval_policy: OnRequest, sandbox_policy: SandboxPolicy::DangerFullAccess, - action: ActionKind::ApplyPatchFunction { - target: TargetPath::Workspace("apply_patch_function.txt"), - content: "function-apply-patch", + action: ActionKind::ApplyPatchFreeform { + target: TargetPath::Workspace("apply_patch_freeform.txt"), + content: "freeform-apply-patch", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.4"), outcome: Outcome::Auto, expectation: Expectation::PatchApplied { - target: TargetPath::Workspace("apply_patch_function.txt"), - content: "function-apply-patch", + target: TargetPath::Workspace("apply_patch_freeform.txt"), + content: "freeform-apply-patch", }, }, ScenarioSpec { - name: "apply_patch_function_danger_allows_outside_workspace", + name: "apply_patch_freeform_danger_allows_outside_workspace", approval_policy: OnRequest, sandbox_policy: SandboxPolicy::DangerFullAccess, - action: ActionKind::ApplyPatchFunction { - target: TargetPath::OutsideWorkspace("apply_patch_function_danger.txt"), - content: "function-patch-danger", + action: ActionKind::ApplyPatchFreeform { + target: TargetPath::OutsideWorkspace("apply_patch_freeform_danger.txt"), + content: "freeform-patch-danger", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![Feature::ApplyPatchFreeform], model_override: Some("gpt-5.4"), outcome: Outcome::Auto, expectation: Expectation::PatchApplied { - target: TargetPath::OutsideWorkspace("apply_patch_function_danger.txt"), - content: "function-patch-danger", + target: TargetPath::OutsideWorkspace("apply_patch_freeform_danger.txt"), + content: "freeform-patch-danger", }, }, ScenarioSpec { - name: "apply_patch_function_outside_requires_patch_approval", + name: "apply_patch_freeform_outside_requires_patch_approval", approval_policy: OnRequest, sandbox_policy: workspace_write(false), - action: ActionKind::ApplyPatchFunction { - target: TargetPath::OutsideWorkspace("apply_patch_function_outside.txt"), - content: "function-patch-outside", + action: ActionKind::ApplyPatchFreeform { + target: TargetPath::OutsideWorkspace("apply_patch_freeform_outside.txt"), + content: "freeform-patch-outside", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], @@ -1391,17 +1391,17 @@ fn scenarios() -> Vec { expected_reason: None, }, expectation: Expectation::PatchApplied { - target: TargetPath::OutsideWorkspace("apply_patch_function_outside.txt"), - content: "function-patch-outside", + target: TargetPath::OutsideWorkspace("apply_patch_freeform_outside.txt"), + content: "freeform-patch-outside", }, }, ScenarioSpec { - name: "apply_patch_function_outside_denied_blocks_patch", + name: "apply_patch_freeform_outside_denied_blocks_patch", approval_policy: OnRequest, sandbox_policy: workspace_write(false), - action: ActionKind::ApplyPatchFunction { - target: TargetPath::OutsideWorkspace("apply_patch_function_outside_denied.txt"), - content: "function-patch-outside-denied", + action: ActionKind::ApplyPatchFreeform { + target: TargetPath::OutsideWorkspace("apply_patch_freeform_outside_denied.txt"), + content: "freeform-patch-outside-denied", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], @@ -1411,7 +1411,7 @@ fn scenarios() -> Vec { expected_reason: None, }, expectation: Expectation::FileNotCreated { - target: TargetPath::OutsideWorkspace("apply_patch_function_outside_denied.txt"), + target: TargetPath::OutsideWorkspace("apply_patch_freeform_outside_denied.txt"), message_contains: &["patch rejected by user"], }, }, @@ -1436,12 +1436,12 @@ fn scenarios() -> Vec { }, }, ScenarioSpec { - name: "apply_patch_function_unless_trusted_requires_patch_approval", + name: "apply_patch_freeform_unless_trusted_requires_patch_approval", approval_policy: UnlessTrusted, sandbox_policy: workspace_write(false), - action: ActionKind::ApplyPatchFunction { - target: TargetPath::Workspace("apply_patch_function_unless_trusted.txt"), - content: "function-patch-unless-trusted", + action: ActionKind::ApplyPatchFreeform { + target: TargetPath::Workspace("apply_patch_freeform_unless_trusted.txt"), + content: "freeform-patch-unless-trusted", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], @@ -1451,24 +1451,24 @@ fn scenarios() -> Vec { expected_reason: None, }, expectation: Expectation::PatchApplied { - target: TargetPath::Workspace("apply_patch_function_unless_trusted.txt"), - content: "function-patch-unless-trusted", + target: TargetPath::Workspace("apply_patch_freeform_unless_trusted.txt"), + content: "freeform-patch-unless-trusted", }, }, ScenarioSpec { - name: "apply_patch_function_never_rejects_outside_workspace", + name: "apply_patch_freeform_never_rejects_outside_workspace", approval_policy: Never, sandbox_policy: workspace_write(false), - action: ActionKind::ApplyPatchFunction { - target: TargetPath::OutsideWorkspace("apply_patch_function_never.txt"), - content: "function-patch-never", + action: ActionKind::ApplyPatchFreeform { + target: TargetPath::OutsideWorkspace("apply_patch_freeform_never.txt"), + content: "freeform-patch-never", }, sandbox_permissions: SandboxPermissions::UseDefault, features: vec![], model_override: Some("gpt-5.4"), outcome: Outcome::Auto, expectation: Expectation::FileNotCreated { - target: TargetPath::OutsideWorkspace("apply_patch_function_never.txt"), + target: TargetPath::OutsideWorkspace("apply_patch_freeform_never.txt"), message_contains: &[ "patch rejected: writing outside of the project; rejected by user approval settings", ], @@ -1812,7 +1812,7 @@ async fn run_scenario_group(group: ScenarioGroup) -> Result<()> { fn scenario_group(scenario: &ScenarioSpec) -> ScenarioGroup { match &scenario.action { - ActionKind::ApplyPatchFunction { .. } | ActionKind::ApplyPatchShell { .. } => { + ActionKind::ApplyPatchFreeform { .. } | ActionKind::ApplyPatchShell { .. } => { ScenarioGroup::ApplyPatch } ActionKind::RunUnifiedExecCommand { .. } => ScenarioGroup::UnifiedExec, @@ -1982,7 +1982,12 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { } } - let output_item = results_mock.single_request().function_call_output(call_id); + let output_request = results_mock.single_request(); + let output_item = if matches!(scenario.action, ActionKind::ApplyPatchFreeform { .. }) { + output_request.custom_tool_call_output(call_id) + } else { + output_request.function_call_output(call_id) + }; let result = parse_result(&output_item); eprintln!( "approval scenario {} result: exit_code={:?} stdout={:?}", @@ -2035,7 +2040,7 @@ async fn approving_apply_patch_for_session_skips_future_prompts_for_same_file() &server, sse(vec![ ev_response_created("resp-1"), - ev_apply_patch_function_call(call_id_1, &patch_add), + ev_apply_patch_custom_tool_call(call_id_1, &patch_add), ev_completed("resp-1"), ]), ) @@ -2070,7 +2075,7 @@ async fn approving_apply_patch_for_session_skips_future_prompts_for_same_file() &server, sse(vec![ ev_response_created("resp-3"), - ev_apply_patch_function_call(call_id_2, &patch_update), + ev_apply_patch_custom_tool_call(call_id_2, &patch_update), ev_completed("resp-3"), ]), ) diff --git a/codex-rs/core/tests/suite/codex_delegate.rs b/codex-rs/core/tests/suite/codex_delegate.rs index 12669a601c..cc31aa5c77 100644 --- a/codex-rs/core/tests/suite/codex_delegate.rs +++ b/codex-rs/core/tests/suite/codex_delegate.rs @@ -7,7 +7,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget; -use core_test_support::responses::ev_apply_patch_function_call; +use core_test_support::responses::ev_apply_patch_custom_tool_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; @@ -127,7 +127,7 @@ async fn codex_delegate_forwards_patch_approval_and_proceeds_on_decision() { let patch = "*** Begin Patch\n*** Add File: delegated.txt\n+hello\n*** End Patch\n"; let sse1 = sse(vec![ ev_response_created("resp-1"), - ev_apply_patch_function_call(call_id, patch), + ev_apply_patch_custom_tool_call(call_id, patch), ev_completed("resp-1"), ]); let review_json = serde_json::json!({ diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 92c8c10a0f..caf4642b0c 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -23,7 +23,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::hooks::trust_discovered_hooks; use core_test_support::hooks::trust_hooks; use core_test_support::managed_network_requirements_loader; -use core_test_support::responses::ev_apply_patch_function_call; +use core_test_support::responses::ev_apply_patch_custom_tool_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; @@ -1524,7 +1524,7 @@ async fn permission_request_hook_allows_apply_patch_with_write_alias() -> Result vec![ sse(vec![ ev_response_created("resp-1"), - ev_apply_patch_function_call(call_id, &patch), + ev_apply_patch_custom_tool_call(call_id, &patch), ev_completed("resp-1"), ]), sse(vec![ @@ -1563,7 +1563,7 @@ async fn permission_request_hook_allows_apply_patch_with_write_alias() -> Result let requests = responses.requests(); assert_eq!(requests.len(), 2); - requests[1].function_call_output(call_id); + requests[1].custom_tool_call_output(call_id); assert!( target_path.exists(), "approved apply_patch should create the out-of-workspace file" @@ -2619,7 +2619,7 @@ async fn pre_tool_use_blocks_apply_patch_before_execution() -> Result<()> { vec![ sse(vec![ ev_response_created("resp-1"), - ev_apply_patch_function_call(call_id, &patch), + ev_apply_patch_custom_tool_call(call_id, &patch), ev_completed("resp-1"), ]), sse(vec![ @@ -2652,7 +2652,7 @@ async fn pre_tool_use_blocks_apply_patch_before_execution() -> Result<()> { let requests = responses.requests(); assert_eq!(requests.len(), 2); - let output_item = requests[1].function_call_output(call_id); + let output_item = requests[1].custom_tool_call_output(call_id); let output = output_item .get("output") .and_then(Value::as_str) @@ -2693,7 +2693,7 @@ async fn pre_tool_use_blocks_apply_patch_with_write_alias() -> Result<()> { vec![ sse(vec![ ev_response_created("resp-1"), - ev_apply_patch_function_call(call_id, &patch), + ev_apply_patch_custom_tool_call(call_id, &patch), ev_completed("resp-1"), ]), sse(vec![ @@ -2724,7 +2724,7 @@ async fn pre_tool_use_blocks_apply_patch_with_write_alias() -> Result<()> { let requests = responses.requests(); assert_eq!(requests.len(), 2); - let output_item = requests[1].function_call_output(call_id); + let output_item = requests[1].custom_tool_call_output(call_id); let output = output_item .get("output") .and_then(Value::as_str) @@ -3363,7 +3363,7 @@ async fn post_tool_use_records_additional_context_for_apply_patch() -> Result<() vec![ sse(vec![ ev_response_created("resp-1"), - ev_apply_patch_function_call(call_id, &patch), + ev_apply_patch_custom_tool_call(call_id, &patch), ev_completed("resp-1"), ]), sse(vec![ @@ -3451,7 +3451,7 @@ async fn post_tool_use_records_apply_patch_context_with_edit_alias() -> Result<( vec![ sse(vec![ ev_response_created("resp-1"), - ev_apply_patch_function_call(call_id, &patch), + ev_apply_patch_custom_tool_call(call_id, &patch), ev_completed("resp-1"), ]), sse(vec![ diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 94465c18da..30b887ab08 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -17,7 +17,7 @@ use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; -use core_test_support::responses::ev_apply_patch_function_call; +use core_test_support::responses::ev_apply_patch_custom_tool_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; @@ -394,7 +394,7 @@ async fn apply_patch_after_request_permissions(strict_auto_review: bool) -> Resu ]), sse(vec![ ev_response_created(&format!("{response_prefix}-2")), - ev_apply_patch_function_call("apply-patch-call", &patch), + ev_apply_patch_custom_tool_call("apply-patch-call", &patch), ev_completed(&format!("{response_prefix}-2")), ]), ]; @@ -475,7 +475,22 @@ async fn apply_patch_after_request_permissions(strict_auto_review: bool) -> Resu } let patch_output = responses - .function_call_output_text("apply-patch-call") + .requests() + .into_iter() + .find_map(|request| { + request + .input() + .into_iter() + .find(|item| { + item.get("type").and_then(Value::as_str) == Some("custom_tool_call_output") + && item.get("call_id").and_then(Value::as_str) == Some("apply-patch-call") + }) + .and_then(|item| { + item.get("output") + .and_then(Value::as_str) + .map(str::to_string) + }) + }) .map(|output| json!({ "output": output })) .unwrap_or_else(|| panic!("expected apply-patch-call output")); let (exit_code, stdout) = parse_result(&patch_output); diff --git a/codex-rs/core/tests/suite/shell_serialization.rs b/codex-rs/core/tests/suite/shell_serialization.rs index ed474114ff..01b1563a6b 100644 --- a/codex-rs/core/tests/suite/shell_serialization.rs +++ b/codex-rs/core/tests/suite/shell_serialization.rs @@ -472,7 +472,6 @@ $"#; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] async fn apply_patch_custom_tool_output_is_structured( @@ -518,7 +517,6 @@ A {file_name} #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] async fn apply_patch_custom_tool_call_creates_file( @@ -566,7 +564,6 @@ A {file_name} #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] async fn apply_patch_custom_tool_call_updates_existing_file( @@ -619,7 +616,6 @@ M {file_name} #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] async fn apply_patch_custom_tool_call_reports_failure_output( @@ -664,20 +660,17 @@ async fn apply_patch_custom_tool_call_reports_failure_output( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[test_case(ApplyPatchModelOutput::Freeform)] -#[test_case(ApplyPatchModelOutput::Function)] #[test_case(ApplyPatchModelOutput::Shell)] #[test_case(ApplyPatchModelOutput::ShellViaHeredoc)] -async fn apply_patch_function_call_output_is_structured( - output_type: ApplyPatchModelOutput, -) -> Result<()> { +async fn apply_patch_tool_output_is_structured(output_type: ApplyPatchModelOutput) -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness().await?; let call_id = "apply-patch-function"; - let file_name = "function_apply_patch.txt"; + let file_name = "freeform_apply_patch.txt"; let patch = - format!("*** Begin Patch\n*** Add File: {file_name}\n+via function call\n*** End Patch\n"); + format!("*** Begin Patch\n*** Add File: {file_name}\n+via apply_patch\n*** End Patch\n"); mount_apply_patch( &harness, call_id, @@ -689,7 +682,7 @@ async fn apply_patch_function_call_output_is_structured( harness .test() .submit_turn_with_permission_profile( - "apply the patch via function-call apply_patch", + "apply the patch via freeform apply_patch", PermissionProfile::Disabled, ) .await?; diff --git a/codex-rs/core/tests/suite/tool_harness.rs b/codex-rs/core/tests/suite/tool_harness.rs index a69ec3f7f6..a2017c99b3 100644 --- a/codex-rs/core/tests/suite/tool_harness.rs +++ b/codex-rs/core/tests/suite/tool_harness.rs @@ -14,7 +14,7 @@ use codex_protocol::user_input::UserInput; use core_test_support::assert_regex_match; use core_test_support::responses; use core_test_support::responses::ResponsesRequest; -use core_test_support::responses::ev_apply_patch_function_call; +use core_test_support::responses::ev_apply_patch_custom_tool_call; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call; @@ -47,6 +47,24 @@ fn call_output(req: &ResponsesRequest, call_id: &str) -> (String, Option) (content, success) } +fn custom_call_output(req: &ResponsesRequest, call_id: &str) -> (String, Option) { + let raw = req.custom_tool_call_output(call_id); + assert_eq!( + raw.get("call_id").and_then(Value::as_str), + Some(call_id), + "mismatched call_id in custom_tool_call_output" + ); + let (content_opt, success) = match req.custom_tool_call_output_content_and_success(call_id) { + Some(values) => values, + None => panic!("custom_tool_call_output present"), + }; + let content = match content_opt { + Some(c) => c, + None => panic!("custom_tool_call_output content present"), + }; + (content, success) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); @@ -107,10 +125,10 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()> let req = second_mock.single_request(); let (output_text, _) = call_output(&req, call_id); - let exec_output: Value = serde_json::from_str(&output_text)?; - assert_eq!(exec_output["metadata"]["exit_code"], 0); - let stdout = exec_output["output"].as_str().expect("stdout field"); - assert_regex_match(r"(?s)^tool harness\n?$", stdout); + assert_regex_match( + r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\ntool harness\n?$", + &output_text, + ); Ok(()) } @@ -328,7 +346,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() let first_response = sse(vec![ ev_response_created("resp-1"), - ev_apply_patch_function_call(call_id, &patch_content), + ev_apply_patch_custom_tool_call(call_id, &patch_content), ev_completed("resp-1"), ]); responses::mount_sse_once(&server, first_response).await; @@ -419,7 +437,7 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<() assert!(patch_end_success); let req = second_mock.single_request(); - let (output_text, _success_flag) = call_output(&req, call_id); + let (output_text, _success_flag) = custom_call_output(&req, call_id); let expected_pattern = format!( r"(?s)^Exit code: 0 @@ -466,7 +484,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { let first_response = sse(vec![ ev_response_created("resp-1"), - ev_apply_patch_function_call(call_id, patch_content), + ev_apply_patch_custom_tool_call(call_id, patch_content), ev_completed("resp-1"), ]); responses::mount_sse_once(&server, first_response).await; @@ -507,7 +525,7 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> { wait_for_event(&codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; let req = second_mock.single_request(); - let (output_text, success_flag) = call_output(&req, call_id); + let (output_text, success_flag) = custom_call_output(&req, call_id); assert!( output_text.contains("apply_patch verification failed"), diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index 7ba76eaa4d..e69cf35da3 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -433,15 +433,10 @@ async fn shell_escalated_permissions_rejected_then_ok() -> Result<()> { .function_call_output_content_and_success(call_id_success) .and_then(|(content, _)| content) .expect("success output string"); - let output_json: Value = serde_json::from_str(&success_output)?; - assert_eq!( - output_json["metadata"]["exit_code"].as_i64(), - Some(0), - "expected exit code 0 after rerunning without escalation", + assert_regex_match( + r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\nshell ok\n?$", + &success_output, ); - let stdout = output_json["output"].as_str().unwrap_or_default(); - let stdout_pattern = r"(?s)^shell ok\n?$"; - assert_regex_match(stdout_pattern, stdout); Ok(()) } diff --git a/codex-rs/exec/tests/suite/apply_patch.rs b/codex-rs/exec/tests/suite/apply_patch.rs index 837c55c4c0..3b0006695e 100644 --- a/codex-rs/exec/tests/suite/apply_patch.rs +++ b/codex-rs/exec/tests/suite/apply_patch.rs @@ -4,7 +4,6 @@ use anyhow::Context; use assert_cmd::prelude::*; use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1; use core_test_support::responses::ev_apply_patch_custom_tool_call; -use core_test_support::responses::ev_apply_patch_function_call; use core_test_support::responses::ev_completed; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; @@ -71,7 +70,7 @@ async fn test_apply_patch_tool() -> anyhow::Result<()> { ev_completed("request_0"), ]), sse(vec![ - ev_apply_patch_function_call("request_1", update_patch), + ev_apply_patch_custom_tool_call("request_1", update_patch), ev_completed("request_1"), ]), sse(vec![ev_completed("request_2")]), diff --git a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs index 153c2db2ef..dc286fe6d6 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs @@ -63,7 +63,7 @@ fn gpt_5_4_cmb_bedrock_model(priority: i32) -> ModelInfo { default_reasoning_summary: ReasoningSummary::None, support_verbosity: true, default_verbosity: Some(Verbosity::Medium), - apply_patch_tool_type: Some(ApplyPatchToolType::Function), + apply_patch_tool_type: Some(ApplyPatchToolType::Freeform), web_search_tool_type: WebSearchToolType::TextAndImage, truncation_policy: TruncationPolicyConfig::tokens(/*limit*/ 10_000), supports_parallel_tool_calls: true, diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 96179440bd..d51e70ddf1 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -203,7 +203,6 @@ pub enum ConfigShellToolType { #[serde(rename_all = "snake_case")] pub enum ApplyPatchToolType { Freeform, - Function, } #[derive( diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index 2ebf0540f7..4639b404b1 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -219,11 +219,10 @@ impl ToolsConfig { model_shell_type }; - let apply_patch_tool_type = match model_info.apply_patch_tool_type { - Some(ApplyPatchToolType::Freeform) => Some(ApplyPatchToolType::Freeform), - Some(ApplyPatchToolType::Function) => Some(ApplyPatchToolType::Function), - None => include_apply_patch_tool.then_some(ApplyPatchToolType::Function), - }; + let apply_patch_tool_type = model_info + .apply_patch_tool_type + .clone() + .or_else(|| include_apply_patch_tool.then_some(ApplyPatchToolType::Freeform)); let agent_jobs_worker_tools = include_agent_jobs && matches!( diff --git a/codex-rs/tools/src/tool_config_tests.rs b/codex-rs/tools/src/tool_config_tests.rs index 3d1829c4e5..252ad7a320 100644 --- a/codex-rs/tools/src/tool_config_tests.rs +++ b/codex-rs/tools/src/tool_config_tests.rs @@ -156,7 +156,7 @@ fn shell_zsh_fork_prefers_shell_command_over_unified_exec() { } #[test] -fn fallback_apply_patch_models_use_function_tool_by_default() { +fn fallback_apply_patch_models_use_freeform_tool_by_default() { let model_info = model_info(); let features = Features::with_defaults(); @@ -174,7 +174,7 @@ fn fallback_apply_patch_models_use_function_tool_by_default() { assert_eq!( tools_config.apply_patch_tool_type, - Some(ApplyPatchToolType::Function) + Some(ApplyPatchToolType::Freeform) ); } From 46e2250bcfc6a789f7561a0243f20b4c98e78dcf Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 8 May 2026 13:20:05 -0700 Subject: [PATCH 12/27] [codex] Remove legacy after tool use hooks (#21805) ## Why The legacy `AfterToolUse` hook path was still wired through core tool dispatch even though the hooks registry never populated any handlers for it. The supported hook surface is `PostToolUse`, so the old infrastructure was dead code on the hot path. ## What changed - Removed the legacy `AfterToolUse` dispatch from `codex-core` tool execution. - Removed the unused legacy hook payload types and exports from `codex-hooks`. - Simplified legacy notify handling now that `HookEvent` only carries `AfterAgent`. ## Validation - `cargo test -p codex-hooks` - `cargo test -p codex-core registry` --- codex-rs/core/src/tools/registry.rs | 165 +--------------------------- codex-rs/hooks/src/legacy_notify.rs | 3 - codex-rs/hooks/src/lib.rs | 4 - codex-rs/hooks/src/registry.rs | 3 - codex-rs/hooks/src/types.rs | 140 ----------------------- 5 files changed, 3 insertions(+), 312 deletions(-) diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 89c5ea0bf7..01a3b811c5 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -1,7 +1,6 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::Duration; -use std::time::Instant; use crate::function_tool::FunctionCallError; use crate::goals::GoalRuntimeEvent; @@ -20,13 +19,6 @@ use crate::tools::flat_tool_name; use crate::tools::hook_names::HookToolName; use crate::tools::tool_dispatch_trace::ToolDispatchTrace; use crate::util::error_or_panic; -use codex_hooks::HookEvent; -use codex_hooks::HookEventAfterToolUse; -use codex_hooks::HookPayload; -use codex_hooks::HookResult; -use codex_hooks::HookToolInput; -use codex_hooks::HookToolInputLocalShell; -use codex_hooks::HookToolKind; use codex_protocol::models::ResponseInputItem; use codex_protocol::protocol::EventMsg; use codex_tools::ConfiguredToolSpec; @@ -379,7 +371,6 @@ impl ToolRegistry { let response_cell = tokio::sync::Mutex::new(None); let invocation_for_tool = invocation.clone(); - let started = Instant::now(); let result = otel .log_tool_result_with_tags( tool_name_flat.as_ref(), @@ -411,10 +402,9 @@ impl ToolRegistry { }, ) .await; - let duration = started.elapsed(); - let (output_preview, success) = match &result { - Ok((preview, success)) => (preview.clone(), *success), - Err(err) => (err.to_string(), false), + let success = match &result { + Ok((_preview, success)) => *success, + Err(_) => false, }; emit_metric_for_tool_read(&invocation, success).await; let post_tool_use_payload = if success { @@ -441,21 +431,6 @@ impl ToolRegistry { } else { None }; - // Deprecated: this is the legacy AfterToolUse hook. Prefer the new PostToolUse - let hook_abort_error = dispatch_after_tool_use_hook(AfterToolUseHookDispatch { - invocation: &invocation, - output_preview, - success, - executed: true, - duration, - mutating: is_mutating, - }) - .await; - - if let Some(err) = hook_abort_error { - dispatch_trace.record_failed(&err); - return Err(err); - } if let Some(outcome) = &post_tool_use_outcome { record_additional_contexts( @@ -580,140 +555,6 @@ fn unsupported_tool_call_message(payload: &ToolPayload, tool_name: &ToolName) -> } } -// Hooks use a separate wire-facing input type so hook payload JSON stays stable -// and decoupled from core's internal tool runtime representation. -impl From<&ToolPayload> for HookToolInput { - fn from(payload: &ToolPayload) -> Self { - match payload { - ToolPayload::Function { arguments } => HookToolInput::Function { - arguments: arguments.clone(), - }, - ToolPayload::ToolSearch { arguments } => HookToolInput::Function { - arguments: serde_json::json!({ - "query": arguments.query, - "limit": arguments.limit, - }) - .to_string(), - }, - ToolPayload::Custom { input } => HookToolInput::Custom { - input: input.clone(), - }, - ToolPayload::LocalShell { params } => HookToolInput::LocalShell { - params: HookToolInputLocalShell { - command: params.command.clone(), - workdir: params.workdir.clone(), - timeout_ms: params.timeout_ms, - sandbox_permissions: params.sandbox_permissions, - prefix_rule: params.prefix_rule.clone(), - justification: params.justification.clone(), - }, - }, - ToolPayload::Mcp { - server, - tool, - raw_arguments, - } => HookToolInput::Mcp { - server: server.clone(), - tool: tool.clone(), - arguments: raw_arguments.clone(), - }, - } - } -} - -fn hook_tool_kind(tool_input: &HookToolInput) -> HookToolKind { - match tool_input { - HookToolInput::Function { .. } => HookToolKind::Function, - HookToolInput::Custom { .. } => HookToolKind::Custom, - HookToolInput::LocalShell { .. } => HookToolKind::LocalShell, - HookToolInput::Mcp { .. } => HookToolKind::Mcp, - } -} - -struct AfterToolUseHookDispatch<'a> { - invocation: &'a ToolInvocation, - output_preview: String, - success: bool, - executed: bool, - duration: Duration, - mutating: bool, -} - -async fn dispatch_after_tool_use_hook( - dispatch: AfterToolUseHookDispatch<'_>, -) -> Option { - let AfterToolUseHookDispatch { invocation, .. } = dispatch; - let session = invocation.session.as_ref(); - let turn = invocation.turn.as_ref(); - let tool_input = HookToolInput::from(&invocation.payload); - let tool_name = &invocation.tool_name; - let hook_tool_name = flat_tool_name(tool_name); - let hook_outcomes = session - .hooks() - .dispatch(HookPayload { - session_id: session.conversation_id, - cwd: turn.cwd.clone(), - client: turn.app_server_client_name.clone(), - triggered_at: chrono::Utc::now(), - hook_event: HookEvent::AfterToolUse { - event: HookEventAfterToolUse { - turn_id: turn.sub_id.clone(), - call_id: invocation.call_id.clone(), - tool_name: hook_tool_name.into_owned(), - tool_kind: hook_tool_kind(&tool_input), - tool_input, - executed: dispatch.executed, - success: dispatch.success, - duration_ms: u64::try_from(dispatch.duration.as_millis()).unwrap_or(u64::MAX), - mutating: dispatch.mutating, - sandbox: permission_profile_sandbox_tag( - &turn.permission_profile, - turn.windows_sandbox_level, - turn.network.is_some(), - ) - .to_string(), - sandbox_policy: permission_profile_policy_tag( - &turn.permission_profile, - turn.cwd.as_path(), - ) - .to_string(), - output_preview: dispatch.output_preview.clone(), - }, - }, - }) - .await; - - for hook_outcome in hook_outcomes { - let hook_name = hook_outcome.hook_name; - match hook_outcome.result { - HookResult::Success => {} - HookResult::FailedContinue(error) => { - warn!( - call_id = %invocation.call_id, - tool_name = %invocation.tool_name, - hook_name = %hook_name, - error = %error, - "after_tool_use hook failed; continuing" - ); - } - HookResult::FailedAbort(error) => { - warn!( - call_id = %invocation.call_id, - tool_name = %invocation.tool_name, - hook_name = %hook_name, - error = %error, - "after_tool_use hook failed; aborting operation" - ); - return Some(FunctionCallError::Fatal(format!( - "after_tool_use hook '{hook_name}' failed and aborted operation: {error}" - ))); - } - } - } - - None -} - #[cfg(test)] #[path = "registry_tests.rs"] mod tests; diff --git a/codex-rs/hooks/src/legacy_notify.rs b/codex-rs/hooks/src/legacy_notify.rs index b1cd23f877..0e273b421f 100644 --- a/codex-rs/hooks/src/legacy_notify.rs +++ b/codex-rs/hooks/src/legacy_notify.rs @@ -37,9 +37,6 @@ pub fn legacy_notify_json(payload: &HookPayload) -> Result Err(serde_json::Error::io(std::io::Error::other( - "legacy notify payload is only supported for after_agent", - ))), } } diff --git a/codex-rs/hooks/src/lib.rs b/codex-rs/hooks/src/lib.rs index 7faa845077..ec59368d30 100644 --- a/codex-rs/hooks/src/lib.rs +++ b/codex-rs/hooks/src/lib.rs @@ -69,13 +69,9 @@ pub use schema::write_schema_fixtures; pub use types::Hook; pub use types::HookEvent; pub use types::HookEventAfterAgent; -pub use types::HookEventAfterToolUse; pub use types::HookPayload; pub use types::HookResponse; pub use types::HookResult; -pub use types::HookToolInput; -pub use types::HookToolInputLocalShell; -pub use types::HookToolKind; /// Returns the hook event label used in persisted hook-state keys. pub fn hook_event_key_label(event_name: HookEventName) -> &'static str { diff --git a/codex-rs/hooks/src/registry.rs b/codex-rs/hooks/src/registry.rs index 74c9a84539..8b79ee873b 100644 --- a/codex-rs/hooks/src/registry.rs +++ b/codex-rs/hooks/src/registry.rs @@ -46,7 +46,6 @@ pub struct HookListOutcome { #[derive(Clone)] pub struct Hooks { after_agent: Vec, - after_tool_use: Vec, engine: ClaudeHooksEngine, } @@ -76,7 +75,6 @@ impl Hooks { ); Self { after_agent, - after_tool_use: Vec::new(), engine, } } @@ -88,7 +86,6 @@ impl Hooks { fn hooks_for_event(&self, hook_event: &HookEvent) -> &[Hook] { match hook_event { HookEvent::AfterAgent { .. } => &self.after_agent, - HookEvent::AfterToolUse { .. } => &self.after_tool_use, } } diff --git a/codex-rs/hooks/src/types.rs b/codex-rs/hooks/src/types.rs index a01e442c30..6639308aa0 100644 --- a/codex-rs/hooks/src/types.rs +++ b/codex-rs/hooks/src/types.rs @@ -4,7 +4,6 @@ use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; use codex_protocol::ThreadId; -use codex_protocol::models::SandboxPermissions; use codex_utils_absolute_path::AbsolutePathBuf; use futures::future::BoxFuture; use serde::Serialize; @@ -81,62 +80,6 @@ pub struct HookEventAfterAgent { pub last_assistant_message: Option, } -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum HookToolKind { - Function, - Custom, - LocalShell, - Mcp, -} - -#[derive(Debug, Clone, Serialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub struct HookToolInputLocalShell { - pub command: Vec, - pub workdir: Option, - pub timeout_ms: Option, - pub sandbox_permissions: Option, - pub prefix_rule: Option>, - pub justification: Option, -} - -#[derive(Debug, Clone, Serialize, PartialEq)] -#[serde(tag = "input_type", rename_all = "snake_case")] -pub enum HookToolInput { - Function { - arguments: String, - }, - Custom { - input: String, - }, - LocalShell { - params: HookToolInputLocalShell, - }, - Mcp { - server: String, - tool: String, - arguments: String, - }, -} - -#[derive(Debug, Clone, Serialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub struct HookEventAfterToolUse { - pub turn_id: String, - pub call_id: String, - pub tool_name: String, - pub tool_kind: HookToolKind, - pub tool_input: HookToolInput, - pub executed: bool, - pub success: bool, - pub duration_ms: u64, - pub mutating: bool, - pub sandbox: String, - pub sandbox_policy: String, - pub output_preview: String, -} - fn serialize_triggered_at(value: &DateTime, serializer: S) -> Result where S: Serializer, @@ -151,10 +94,6 @@ pub enum HookEvent { #[serde(flatten)] event: HookEventAfterAgent, }, - AfterToolUse { - #[serde(flatten)] - event: HookEventAfterToolUse, - }, } #[cfg(test)] @@ -162,7 +101,6 @@ mod tests { use chrono::TimeZone; use chrono::Utc; use codex_protocol::ThreadId; - use codex_protocol::models::SandboxPermissions; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; @@ -170,11 +108,7 @@ mod tests { use super::HookEvent; use super::HookEventAfterAgent; - use super::HookEventAfterToolUse; use super::HookPayload; - use super::HookToolInput; - use super::HookToolInputLocalShell; - use super::HookToolKind; #[test] fn hook_payload_serializes_stable_wire_shape() { @@ -215,78 +149,4 @@ mod tests { assert_eq!(actual, expected); } - - #[test] - fn after_tool_use_payload_serializes_stable_wire_shape() { - let session_id = ThreadId::new(); - let cwd = test_path_buf("/tmp").abs(); - let payload = HookPayload { - session_id, - cwd: cwd.clone(), - client: None, - triggered_at: Utc - .with_ymd_and_hms(2025, 1, 1, 0, 0, 0) - .single() - .expect("valid timestamp"), - hook_event: HookEvent::AfterToolUse { - event: HookEventAfterToolUse { - turn_id: "turn-2".to_string(), - call_id: "call-1".to_string(), - tool_name: "local_shell".to_string(), - tool_kind: HookToolKind::LocalShell, - tool_input: HookToolInput::LocalShell { - params: HookToolInputLocalShell { - command: vec!["cargo".to_string(), "fmt".to_string()], - workdir: Some("codex-rs".to_string()), - timeout_ms: Some(60_000), - sandbox_permissions: Some(SandboxPermissions::UseDefault), - justification: None, - prefix_rule: None, - }, - }, - executed: true, - success: true, - duration_ms: 42, - mutating: true, - sandbox: "none".to_string(), - sandbox_policy: "danger-full-access".to_string(), - output_preview: "ok".to_string(), - }, - }, - }; - - let actual = serde_json::to_value(payload).expect("serialize hook payload"); - let expected = json!({ - "session_id": session_id.to_string(), - "cwd": cwd.display().to_string(), - "triggered_at": "2025-01-01T00:00:00Z", - "hook_event": { - "event_type": "after_tool_use", - "turn_id": "turn-2", - "call_id": "call-1", - "tool_name": "local_shell", - "tool_kind": "local_shell", - "tool_input": { - "input_type": "local_shell", - "params": { - "command": ["cargo", "fmt"], - "workdir": "codex-rs", - "timeout_ms": 60000, - "sandbox_permissions": "use_default", - "justification": null, - "prefix_rule": null, - }, - }, - "executed": true, - "success": true, - "duration_ms": 42, - "mutating": true, - "sandbox": "none", - "sandbox_policy": "danger-full-access", - "output_preview": "ok", - }, - }); - - assert_eq!(actual, expected); - } } From 7c9731c9af879e2ee4fd4bf92312bbd690a55336 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 8 May 2026 13:29:00 -0700 Subject: [PATCH 13/27] Enable `--deny-warnings` for `cargo shear` (#21616) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary In https://github.com/openai/codex/pull/21584, we disabled doctests for crates that lack any doctests. We can enforce that property via `cargo shear --deny-warnings`: crates that lack doctests will be flagged if doctests are enabled, and crates with doctests will be flagged if doctests are disabled. A few additional notes: - By adding `--deny-warnings`, `cargo shear` also flagged a number of modules that were not reachable at all. Some of those have been removed. - This PR removes a usage of `windows_modules!` (since `cargo shear` and `rustfmt` couldn't see through it) in favor of simple `#[cfg(target_os = "windows")]` macros. As a consequence, many of these files exhibit churn in this PR, since they weren't being formatted by `rustfmt` at all on main. - Again, to make the code more analyzable, this PR also removes some usages of `#[path = "cwd_junction.rs"]` in favor of a more standard module structure. The bin sidecar structure is still retained, but, e.g., `windows-sandbox-rs/src/bin/command_runner.rs‎` was moved to `windows-sandbox-rs/src/bin/command_runner/main.rs`, and so on. --------- Co-authored-by: Codex --- .github/workflows/rust-ci-full.yml | 5 +- .github/workflows/rust-ci.yml | 5 +- codex-rs/Cargo.lock | 1 - codex-rs/Cargo.toml | 3 - .../codex-backend-openapi-models/Cargo.toml | 3 - .../tools/code_mode/execute_handler_tests.rs | 41 -- .../src/tools/handlers/grep_files_tests.rs | 95 ---- .../src/tools/handlers/read_file_tests.rs | 503 ------------------ codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/override_updates.rs | 122 +---- codex-rs/exec-server/src/server/jsonrpc.rs | 53 -- codex-rs/hooks/src/user_notification.rs | 153 ------ codex-rs/mcp-server/src/tool_handlers/mod.rs | 2 - codex-rs/plugin/src/plugin_namespace.rs | 70 --- codex-rs/protocol/Cargo.toml | 4 +- codex-rs/windows-sandbox-rs/Cargo.toml | 7 +- codex-rs/windows-sandbox-rs/src/acl.rs | 37 +- codex-rs/windows-sandbox-rs/src/allow.rs | 37 +- codex-rs/windows-sandbox-rs/src/audit.rs | 19 +- .../main.rs} | 2 +- .../command_runner/win.rs} | 33 +- .../command_runner/win}/cwd_junction.rs | 2 - .../bin/{setup_main.rs => setup_main/main.rs} | 2 +- .../setup_main/win.rs} | 5 +- .../src/{ => bin/setup_main/win}/firewall.rs | 2 - .../setup_main/win}/read_acl_mutex.rs | 9 +- .../{ => bin/setup_main/win}/sandbox_users.rs | 2 - .../setup_main/win}/setup_runtime_bin.rs | 0 codex-rs/windows-sandbox-rs/src/cap.rs | 11 +- codex-rs/windows-sandbox-rs/src/desktop.rs | 8 +- codex-rs/windows-sandbox-rs/src/dpapi.rs | 10 +- .../windows-sandbox-rs/src/elevated/mod.rs | 3 + codex-rs/windows-sandbox-rs/src/env.rs | 9 +- .../src/helper_materialization.rs | 64 +-- codex-rs/windows-sandbox-rs/src/hide_users.rs | 12 +- codex-rs/windows-sandbox-rs/src/identity.rs | 20 +- codex-rs/windows-sandbox-rs/src/lib.rs | 127 +++-- .../src/path_normalization.rs | 5 +- codex-rs/windows-sandbox-rs/src/policy.rs | 29 +- codex-rs/windows-sandbox-rs/src/process.rs | 8 +- .../src/{setup_orchestrator.rs => setup.rs} | 0 codex-rs/windows-sandbox-rs/src/token.rs | 25 +- .../src/unified_exec/{session.rs => mod.rs} | 0 codex-rs/windows-sandbox-rs/src/wfp.rs | 41 +- .../filter_specs.rs} | 24 +- codex-rs/windows-sandbox-rs/src/wfp_setup.rs | 8 +- codex-rs/windows-sandbox-rs/src/winutil.rs | 15 +- 47 files changed, 332 insertions(+), 1305 deletions(-) delete mode 100644 codex-rs/core/src/tools/code_mode/execute_handler_tests.rs delete mode 100644 codex-rs/core/src/tools/handlers/grep_files_tests.rs delete mode 100644 codex-rs/core/src/tools/handlers/read_file_tests.rs delete mode 100644 codex-rs/exec-server/src/server/jsonrpc.rs delete mode 100644 codex-rs/hooks/src/user_notification.rs delete mode 100644 codex-rs/mcp-server/src/tool_handlers/mod.rs delete mode 100644 codex-rs/plugin/src/plugin_namespace.rs rename codex-rs/windows-sandbox-rs/src/bin/{command_runner.rs => command_runner/main.rs} (80%) rename codex-rs/windows-sandbox-rs/src/{elevated/command_runner_win.rs => bin/command_runner/win.rs} (96%) rename codex-rs/windows-sandbox-rs/src/{elevated => bin/command_runner/win}/cwd_junction.rs (99%) rename codex-rs/windows-sandbox-rs/src/bin/{setup_main.rs => setup_main/main.rs} (85%) rename codex-rs/windows-sandbox-rs/src/{setup_main_win.rs => bin/setup_main/win.rs} (99%) rename codex-rs/windows-sandbox-rs/src/{ => bin/setup_main/win}/firewall.rs (99%) rename codex-rs/windows-sandbox-rs/src/{ => bin/setup_main/win}/read_acl_mutex.rs (89%) rename codex-rs/windows-sandbox-rs/src/{ => bin/setup_main/win}/sandbox_users.rs (99%) rename codex-rs/windows-sandbox-rs/src/{ => bin/setup_main/win}/setup_runtime_bin.rs (100%) create mode 100644 codex-rs/windows-sandbox-rs/src/elevated/mod.rs rename codex-rs/windows-sandbox-rs/src/{setup_orchestrator.rs => setup.rs} (100%) rename codex-rs/windows-sandbox-rs/src/unified_exec/{session.rs => mod.rs} (100%) rename codex-rs/windows-sandbox-rs/src/{wfp_filter_specs.rs => wfp/filter_specs.rs} (90%) diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index 8c883a6e2d..de37faa4ca 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -44,10 +44,9 @@ jobs: - 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 diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 243c208436..52322913e5 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -86,10 +86,9 @@ jobs: - 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 diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 10b5cc2351..40ca5c809d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3331,7 +3331,6 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-image", "codex-utils-string", - "codex-utils-template", "encoding_rs", "globset", "http 1.4.0", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 6bda741c9c..adbcdb14ea 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -464,11 +464,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/codex-backend-openapi-models/Cargo.toml b/codex-rs/codex-backend-openapi-models/Cargo.toml index f6ff459b0f..7baf01935d 100644 --- a/codex-rs/codex-backend-openapi-models/Cargo.toml +++ b/codex-rs/codex-backend-openapi-models/Cargo.toml @@ -22,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/core/src/tools/code_mode/execute_handler_tests.rs b/codex-rs/core/src/tools/code_mode/execute_handler_tests.rs deleted file mode 100644 index ed22b337b2..0000000000 --- a/codex-rs/core/src/tools/code_mode/execute_handler_tests.rs +++ /dev/null @@ -1,41 +0,0 @@ -use super::parse_freeform_args; -use pretty_assertions::assert_eq; - -#[test] -fn parse_freeform_args_without_pragma() { - let args = parse_freeform_args("output_text('ok');").expect("parse args"); - assert_eq!(args.code, "output_text('ok');"); - assert_eq!(args.yield_time_ms, None); - assert_eq!(args.max_output_tokens, None); -} - -#[test] -fn parse_freeform_args_with_pragma() { - let input = concat!( - "// @exec: {\"yield_time_ms\": 15000, \"max_output_tokens\": 2000}\n", - "output_text('ok');", - ); - let args = parse_freeform_args(input).expect("parse args"); - assert_eq!(args.code, "output_text('ok');"); - assert_eq!(args.yield_time_ms, Some(15_000)); - assert_eq!(args.max_output_tokens, Some(2_000)); -} - -#[test] -fn parse_freeform_args_rejects_unknown_key() { - let err = parse_freeform_args("// @exec: {\"nope\": 1}\noutput_text('ok');") - .expect_err("expected error"); - assert_eq!( - err.to_string(), - "exec pragma only supports `yield_time_ms` and `max_output_tokens`; got `nope`" - ); -} - -#[test] -fn parse_freeform_args_rejects_missing_source() { - let err = parse_freeform_args("// @exec: {\"yield_time_ms\": 10}").expect_err("expected error"); - assert_eq!( - err.to_string(), - "exec pragma must be followed by JavaScript source on subsequent lines" - ); -} diff --git a/codex-rs/core/src/tools/handlers/grep_files_tests.rs b/codex-rs/core/src/tools/handlers/grep_files_tests.rs deleted file mode 100644 index 0cc247c6f1..0000000000 --- a/codex-rs/core/src/tools/handlers/grep_files_tests.rs +++ /dev/null @@ -1,95 +0,0 @@ -use super::*; -use std::process::Command as StdCommand; -use tempfile::tempdir; - -#[test] -fn parses_basic_results() { - let stdout = b"/tmp/file_a.rs\n/tmp/file_b.rs\n"; - let parsed = parse_results(stdout, 10); - assert_eq!( - parsed, - vec!["/tmp/file_a.rs".to_string(), "/tmp/file_b.rs".to_string()] - ); -} - -#[test] -fn parse_truncates_after_limit() { - let stdout = b"/tmp/file_a.rs\n/tmp/file_b.rs\n/tmp/file_c.rs\n"; - let parsed = parse_results(stdout, 2); - assert_eq!( - parsed, - vec!["/tmp/file_a.rs".to_string(), "/tmp/file_b.rs".to_string()] - ); -} - -#[tokio::test] -async fn run_search_returns_results() -> anyhow::Result<()> { - if !rg_available() { - return Ok(()); - } - let temp = tempdir().expect("create temp dir"); - let dir = temp.path(); - std::fs::write(dir.join("match_one.txt"), "alpha beta gamma").unwrap(); - std::fs::write(dir.join("match_two.txt"), "alpha delta").unwrap(); - std::fs::write(dir.join("other.txt"), "omega").unwrap(); - - let results = run_rg_search("alpha", None, dir, 10, dir).await?; - assert_eq!(results.len(), 2); - assert!(results.iter().any(|path| path.ends_with("match_one.txt"))); - assert!(results.iter().any(|path| path.ends_with("match_two.txt"))); - Ok(()) -} - -#[tokio::test] -async fn run_search_with_glob_filter() -> anyhow::Result<()> { - if !rg_available() { - return Ok(()); - } - let temp = tempdir().expect("create temp dir"); - let dir = temp.path(); - std::fs::write(dir.join("match_one.rs"), "alpha beta gamma").unwrap(); - std::fs::write(dir.join("match_two.txt"), "alpha delta").unwrap(); - - let results = run_rg_search("alpha", Some("*.rs"), dir, 10, dir).await?; - assert_eq!(results.len(), 1); - assert!(results.iter().all(|path| path.ends_with("match_one.rs"))); - Ok(()) -} - -#[tokio::test] -async fn run_search_respects_limit() -> anyhow::Result<()> { - if !rg_available() { - return Ok(()); - } - let temp = tempdir().expect("create temp dir"); - let dir = temp.path(); - std::fs::write(dir.join("one.txt"), "alpha one").unwrap(); - std::fs::write(dir.join("two.txt"), "alpha two").unwrap(); - std::fs::write(dir.join("three.txt"), "alpha three").unwrap(); - - let results = run_rg_search("alpha", None, dir, 2, dir).await?; - assert_eq!(results.len(), 2); - Ok(()) -} - -#[tokio::test] -async fn run_search_handles_no_matches() -> anyhow::Result<()> { - if !rg_available() { - return Ok(()); - } - let temp = tempdir().expect("create temp dir"); - let dir = temp.path(); - std::fs::write(dir.join("one.txt"), "omega").unwrap(); - - let results = run_rg_search("alpha", None, dir, 5, dir).await?; - assert!(results.is_empty()); - Ok(()) -} - -fn rg_available() -> bool { - StdCommand::new("rg") - .arg("--version") - .output() - .map(|output| output.status.success()) - .unwrap_or(false) -} diff --git a/codex-rs/core/src/tools/handlers/read_file_tests.rs b/codex-rs/core/src/tools/handlers/read_file_tests.rs deleted file mode 100644 index 3921a98826..0000000000 --- a/codex-rs/core/src/tools/handlers/read_file_tests.rs +++ /dev/null @@ -1,503 +0,0 @@ -use super::indentation::read_block; -use super::slice::read; -use super::*; -use pretty_assertions::assert_eq; -use tempfile::NamedTempFile; - -#[tokio::test] -async fn reads_requested_range() -> anyhow::Result<()> { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - write!( - temp, - "alpha -beta -gamma -" - )?; - - let lines = read(temp.path(), 2, 2).await?; - assert_eq!(lines, vec!["L2: beta".to_string(), "L3: gamma".to_string()]); - Ok(()) -} - -#[tokio::test] -async fn errors_when_offset_exceeds_length() -> anyhow::Result<()> { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - writeln!(temp, "only")?; - - let err = read(temp.path(), 3, 1) - .await - .expect_err("offset exceeds length"); - assert_eq!( - err, - FunctionCallError::RespondToModel("offset exceeds file length".to_string()) - ); - Ok(()) -} - -#[tokio::test] -async fn reads_non_utf8_lines() -> anyhow::Result<()> { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - temp.as_file_mut().write_all(b"\xff\xfe\nplain\n")?; - - let lines = read(temp.path(), 1, 2).await?; - let expected_first = format!("L1: {}{}", '\u{FFFD}', '\u{FFFD}'); - assert_eq!(lines, vec![expected_first, "L2: plain".to_string()]); - Ok(()) -} - -#[tokio::test] -async fn trims_crlf_endings() -> anyhow::Result<()> { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - write!(temp, "one\r\ntwo\r\n")?; - - let lines = read(temp.path(), 1, 2).await?; - assert_eq!(lines, vec!["L1: one".to_string(), "L2: two".to_string()]); - Ok(()) -} - -#[tokio::test] -async fn respects_limit_even_with_more_lines() -> anyhow::Result<()> { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - write!( - temp, - "first -second -third -" - )?; - - let lines = read(temp.path(), 1, 2).await?; - assert_eq!( - lines, - vec!["L1: first".to_string(), "L2: second".to_string()] - ); - Ok(()) -} - -#[tokio::test] -async fn truncates_lines_longer_than_max_length() -> anyhow::Result<()> { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - let long_line = "x".repeat(MAX_LINE_LENGTH + 50); - writeln!(temp, "{long_line}")?; - - let lines = read(temp.path(), 1, 1).await?; - let expected = "x".repeat(MAX_LINE_LENGTH); - assert_eq!(lines, vec![format!("L1: {expected}")]); - Ok(()) -} - -#[tokio::test] -async fn indentation_mode_captures_block() -> anyhow::Result<()> { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - write!( - temp, - "fn outer() {{ - if cond {{ - inner(); - }} - tail(); -}} -" - )?; - - let options = IndentationArgs { - anchor_line: Some(3), - include_siblings: false, - max_levels: 1, - ..Default::default() - }; - - let lines = read_block(temp.path(), 3, 10, options).await?; - - assert_eq!( - lines, - vec![ - "L2: if cond {".to_string(), - "L3: inner();".to_string(), - "L4: }".to_string() - ] - ); - Ok(()) -} - -#[tokio::test] -async fn indentation_mode_expands_parents() -> anyhow::Result<()> { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - write!( - temp, - "mod root {{ - fn outer() {{ - if cond {{ - inner(); - }} - }} -}} -" - )?; - - let mut options = IndentationArgs { - anchor_line: Some(4), - max_levels: 2, - ..Default::default() - }; - - let lines = read_block(temp.path(), 4, 50, options.clone()).await?; - assert_eq!( - lines, - vec![ - "L2: fn outer() {".to_string(), - "L3: if cond {".to_string(), - "L4: inner();".to_string(), - "L5: }".to_string(), - "L6: }".to_string(), - ] - ); - - options.max_levels = 3; - let expanded = read_block(temp.path(), 4, 50, options).await?; - assert_eq!( - expanded, - vec![ - "L1: mod root {".to_string(), - "L2: fn outer() {".to_string(), - "L3: if cond {".to_string(), - "L4: inner();".to_string(), - "L5: }".to_string(), - "L6: }".to_string(), - "L7: }".to_string(), - ] - ); - Ok(()) -} - -#[tokio::test] -async fn indentation_mode_respects_sibling_flag() -> anyhow::Result<()> { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - write!( - temp, - "fn wrapper() {{ - if first {{ - do_first(); - }} - if second {{ - do_second(); - }} -}} -" - )?; - - let mut options = IndentationArgs { - anchor_line: Some(3), - include_siblings: false, - max_levels: 1, - ..Default::default() - }; - - let lines = read_block(temp.path(), 3, 50, options.clone()).await?; - assert_eq!( - lines, - vec![ - "L2: if first {".to_string(), - "L3: do_first();".to_string(), - "L4: }".to_string(), - ] - ); - - options.include_siblings = true; - let with_siblings = read_block(temp.path(), 3, 50, options).await?; - assert_eq!( - with_siblings, - vec![ - "L2: if first {".to_string(), - "L3: do_first();".to_string(), - "L4: }".to_string(), - "L5: if second {".to_string(), - "L6: do_second();".to_string(), - "L7: }".to_string(), - ] - ); - Ok(()) -} - -#[tokio::test] -async fn indentation_mode_handles_python_sample() -> anyhow::Result<()> { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - write!( - temp, - "class Foo: - def __init__(self, size): - self.size = size - def double(self, value): - if value is None: - return 0 - result = value * self.size - return result -class Bar: - def compute(self): - helper = Foo(2) - return helper.double(5) -" - )?; - - let options = IndentationArgs { - anchor_line: Some(7), - include_siblings: true, - max_levels: 1, - ..Default::default() - }; - - let lines = read_block(temp.path(), 1, 200, options).await?; - assert_eq!( - lines, - vec![ - "L2: def __init__(self, size):".to_string(), - "L3: self.size = size".to_string(), - "L4: def double(self, value):".to_string(), - "L5: if value is None:".to_string(), - "L6: return 0".to_string(), - "L7: result = value * self.size".to_string(), - "L8: return result".to_string(), - ] - ); - Ok(()) -} - -#[tokio::test] -#[ignore] -async fn indentation_mode_handles_javascript_sample() -> anyhow::Result<()> { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - write!( - temp, - "export function makeThing() {{ - const cache = new Map(); - function ensure(key) {{ - if (!cache.has(key)) {{ - cache.set(key, []); - }} - return cache.get(key); - }} - const handlers = {{ - init() {{ - console.log(\"init\"); - }}, - run() {{ - if (Math.random() > 0.5) {{ - return \"heads\"; - }} - return \"tails\"; - }}, - }}; - return {{ cache, handlers }}; -}} -export function other() {{ - return makeThing(); -}} -" - )?; - - let options = IndentationArgs { - anchor_line: Some(15), - max_levels: 1, - ..Default::default() - }; - - let lines = read_block(temp.path(), 15, 200, options).await?; - assert_eq!( - lines, - vec![ - "L10: init() {".to_string(), - "L11: console.log(\"init\");".to_string(), - "L12: },".to_string(), - "L13: run() {".to_string(), - "L14: if (Math.random() > 0.5) {".to_string(), - "L15: return \"heads\";".to_string(), - "L16: }".to_string(), - "L17: return \"tails\";".to_string(), - "L18: },".to_string(), - ] - ); - Ok(()) -} - -fn write_cpp_sample() -> anyhow::Result { - let mut temp = NamedTempFile::new()?; - use std::io::Write as _; - write!( - temp, - "#include -#include - -namespace sample {{ -class Runner {{ -public: - void setup() {{ - if (enabled_) {{ - init(); - }} - }} - - // Run the code - int run() const {{ - switch (mode_) {{ - case Mode::Fast: - return fast(); - case Mode::Slow: - return slow(); - default: - return fallback(); - }} - }} - -private: - bool enabled_ = false; - Mode mode_ = Mode::Fast; - - int fast() const {{ - return 1; - }} -}}; -}} // namespace sample -" - )?; - Ok(temp) -} - -#[tokio::test] -async fn indentation_mode_handles_cpp_sample_shallow() -> anyhow::Result<()> { - let temp = write_cpp_sample()?; - - let options = IndentationArgs { - include_siblings: false, - anchor_line: Some(18), - max_levels: 1, - ..Default::default() - }; - - let lines = read_block(temp.path(), 18, 200, options).await?; - assert_eq!( - lines, - vec![ - "L15: switch (mode_) {".to_string(), - "L16: case Mode::Fast:".to_string(), - "L17: return fast();".to_string(), - "L18: case Mode::Slow:".to_string(), - "L19: return slow();".to_string(), - "L20: default:".to_string(), - "L21: return fallback();".to_string(), - "L22: }".to_string(), - ] - ); - Ok(()) -} - -#[tokio::test] -async fn indentation_mode_handles_cpp_sample() -> anyhow::Result<()> { - let temp = write_cpp_sample()?; - - let options = IndentationArgs { - include_siblings: false, - anchor_line: Some(18), - max_levels: 2, - ..Default::default() - }; - - let lines = read_block(temp.path(), 18, 200, options).await?; - assert_eq!( - lines, - vec![ - "L13: // Run the code".to_string(), - "L14: int run() const {".to_string(), - "L15: switch (mode_) {".to_string(), - "L16: case Mode::Fast:".to_string(), - "L17: return fast();".to_string(), - "L18: case Mode::Slow:".to_string(), - "L19: return slow();".to_string(), - "L20: default:".to_string(), - "L21: return fallback();".to_string(), - "L22: }".to_string(), - "L23: }".to_string(), - ] - ); - Ok(()) -} - -#[tokio::test] -async fn indentation_mode_handles_cpp_sample_no_headers() -> anyhow::Result<()> { - let temp = write_cpp_sample()?; - - let options = IndentationArgs { - include_siblings: false, - include_header: false, - anchor_line: Some(18), - max_levels: 2, - ..Default::default() - }; - - let lines = read_block(temp.path(), 18, 200, options).await?; - assert_eq!( - lines, - vec![ - "L14: int run() const {".to_string(), - "L15: switch (mode_) {".to_string(), - "L16: case Mode::Fast:".to_string(), - "L17: return fast();".to_string(), - "L18: case Mode::Slow:".to_string(), - "L19: return slow();".to_string(), - "L20: default:".to_string(), - "L21: return fallback();".to_string(), - "L22: }".to_string(), - "L23: }".to_string(), - ] - ); - Ok(()) -} - -#[tokio::test] -async fn indentation_mode_handles_cpp_sample_siblings() -> anyhow::Result<()> { - let temp = write_cpp_sample()?; - - let options = IndentationArgs { - include_siblings: true, - include_header: false, - anchor_line: Some(18), - max_levels: 2, - ..Default::default() - }; - - let lines = read_block(temp.path(), 18, 200, options).await?; - assert_eq!( - lines, - vec![ - "L7: void setup() {".to_string(), - "L8: if (enabled_) {".to_string(), - "L9: init();".to_string(), - "L10: }".to_string(), - "L11: }".to_string(), - "L12: ".to_string(), - "L13: // Run the code".to_string(), - "L14: int run() const {".to_string(), - "L15: switch (mode_) {".to_string(), - "L16: case Mode::Fast:".to_string(), - "L17: return fast();".to_string(), - "L18: case Mode::Slow:".to_string(), - "L19: return slow();".to_string(), - "L20: default:".to_string(), - "L21: return fallback();".to_string(), - "L22: }".to_string(), - "L23: }".to_string(), - ] - ); - Ok(()) -} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index ad3280ebf0..0c91654c83 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -65,6 +65,7 @@ mod models_cache_ttl; mod models_etag_responses; mod openai_file_mcp; mod otel; +mod override_updates; mod pending_input; mod permissions_messages; mod personality; diff --git a/codex-rs/core/tests/suite/override_updates.rs b/codex-rs/core/tests/suite/override_updates.rs index 63d2b6ed43..d0e949947a 100644 --- a/codex-rs/core/tests/suite/override_updates.rs +++ b/codex-rs/core/tests/suite/override_updates.rs @@ -1,25 +1,15 @@ use anyhow::Result; use codex_core::config::Constrained; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::COLLABORATION_MODE_CLOSE_TAG; -use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; -use codex_protocol::protocol::RolloutItem; -use codex_protocol::protocol::RolloutLine; -use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; -use codex_protocol::models::ContentItem; -use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; -use pretty_assertions::assert_eq; -use std::path::Path; -use std::time::Duration; use tempfile::TempDir; fn collab_mode_with_instructions(instructions: Option<&str>) -> CollaborationMode { @@ -33,77 +23,9 @@ fn collab_mode_with_instructions(instructions: Option<&str>) -> CollaborationMod } } -fn collab_xml(text: &str) -> String { - format!("{COLLABORATION_MODE_OPEN_TAG}{text}{COLLABORATION_MODE_CLOSE_TAG}") -} - -async fn read_rollout_text(path: &Path) -> anyhow::Result { - for _ in 0..50 { - if path.exists() - && let Ok(text) = std::fs::read_to_string(path) - && !text.trim().is_empty() - { - return Ok(text); - } - tokio::time::sleep(Duration::from_millis(20)).await; - } - Ok(std::fs::read_to_string(path)?) -} - -fn rollout_developer_texts(text: &str) -> Vec { - let mut texts = Vec::new(); - for line in text.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let rollout: RolloutLine = match serde_json::from_str(trimmed) { - Ok(rollout) => rollout, - Err(_) => continue, - }; - if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = - rollout.item - && role == "developer" - { - for item in content { - if let ContentItem::InputText { text } = item { - texts.push(text); - } - } - } - } - texts -} - -fn rollout_environment_texts(text: &str) -> Vec { - let mut texts = Vec::new(); - for line in text.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - let rollout: RolloutLine = match serde_json::from_str(trimmed) { - Ok(rollout) => rollout, - Err(_) => continue, - }; - if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = - rollout.item - && role == "user" - { - for item in content { - if let ContentItem::InputText { text } = item - && text.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG) - { - texts.push(text); - } - } - } - } - texts -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn override_turn_context_without_user_turn_does_not_record_permissions_update() -> Result<()> { +async fn override_turn_context_without_user_turn_does_not_record_permissions_update() -> Result<()> +{ skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -133,22 +55,17 @@ async fn override_turn_context_without_user_turn_does_not_record_permissions_upd wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; let rollout_path = test.codex.rollout_path().expect("rollout path"); - let rollout_text = read_rollout_text(&rollout_path).await?; - let developer_texts = rollout_developer_texts(&rollout_text); - let approval_texts: Vec<&String> = developer_texts - .iter() - .filter(|text| text.contains("`approval_policy`")) - .collect(); assert!( - approval_texts.is_empty(), - "did not expect permissions updates before a new user turn: {approval_texts:?}" + !rollout_path.exists(), + "did not expect a rollout before a new user turn" ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn override_turn_context_without_user_turn_does_not_record_environment_update() -> Result<()> { +async fn override_turn_context_without_user_turn_does_not_record_environment_update() -> Result<()> +{ skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -176,18 +93,17 @@ async fn override_turn_context_without_user_turn_does_not_record_environment_upd wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; let rollout_path = test.codex.rollout_path().expect("rollout path"); - let rollout_text = read_rollout_text(&rollout_path).await?; - let env_texts = rollout_environment_texts(&rollout_text); assert!( - env_texts.is_empty(), - "did not expect environment updates before a new user turn: {env_texts:?}" + !rollout_path.exists(), + "did not expect a rollout before a new user turn" ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn override_turn_context_without_user_turn_does_not_record_collaboration_update() -> Result<()> { +async fn override_turn_context_without_user_turn_does_not_record_collaboration_update() -> Result<()> +{ skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -216,14 +132,10 @@ async fn override_turn_context_without_user_turn_does_not_record_collaboration_u wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::ShutdownComplete)).await; let rollout_path = test.codex.rollout_path().expect("rollout path"); - let rollout_text = read_rollout_text(&rollout_path).await?; - let developer_texts = rollout_developer_texts(&rollout_text); - let collab_text = collab_xml(collab_text); - let collab_count = developer_texts - .iter() - .filter(|text| text.as_str() == collab_text.as_str()) - .count(); - assert_eq!(collab_count, 0); + assert!( + !rollout_path.exists(), + "did not expect a rollout before a new user turn" + ); Ok(()) } diff --git a/codex-rs/exec-server/src/server/jsonrpc.rs b/codex-rs/exec-server/src/server/jsonrpc.rs deleted file mode 100644 index f81abd06eb..0000000000 --- a/codex-rs/exec-server/src/server/jsonrpc.rs +++ /dev/null @@ -1,53 +0,0 @@ -use codex_app_server_protocol::JSONRPCError; -use codex_app_server_protocol::JSONRPCErrorError; -use codex_app_server_protocol::JSONRPCMessage; -use codex_app_server_protocol::JSONRPCResponse; -use codex_app_server_protocol::RequestId; -use serde_json::Value; - -pub(crate) fn invalid_request(message: String) -> JSONRPCErrorError { - JSONRPCErrorError { - code: -32600, - data: None, - message, - } -} - -pub(crate) fn invalid_params(message: String) -> JSONRPCErrorError { - JSONRPCErrorError { - code: -32602, - data: None, - message, - } -} - -pub(crate) fn method_not_found(message: String) -> JSONRPCErrorError { - JSONRPCErrorError { - code: -32601, - data: None, - message, - } -} - -pub(crate) fn response_message( - request_id: RequestId, - result: Result, -) -> JSONRPCMessage { - match result { - Ok(result) => JSONRPCMessage::Response(JSONRPCResponse { - id: request_id, - result, - }), - Err(error) => JSONRPCMessage::Error(JSONRPCError { - id: request_id, - error, - }), - } -} - -pub(crate) fn invalid_request_message(reason: String) -> JSONRPCMessage { - JSONRPCMessage::Error(JSONRPCError { - id: RequestId::Integer(-1), - error: invalid_request(reason), - }) -} diff --git a/codex-rs/hooks/src/user_notification.rs b/codex-rs/hooks/src/user_notification.rs deleted file mode 100644 index 97af09a3b9..0000000000 --- a/codex-rs/hooks/src/user_notification.rs +++ /dev/null @@ -1,153 +0,0 @@ -use std::process::Stdio; -use std::sync::Arc; - -use serde::Serialize; - -use crate::Hook; -use crate::HookEvent; -use crate::HookPayload; -use crate::HookResult; -use crate::command_from_argv; - -/// Legacy notify payload appended as the final argv argument for backward compatibility. -#[derive(Debug, Clone, PartialEq, Serialize)] -#[serde(tag = "type", rename_all = "kebab-case")] -enum UserNotification { - #[serde(rename_all = "kebab-case")] - AgentTurnComplete { - thread_id: String, - turn_id: String, - cwd: String, - #[serde(skip_serializing_if = "Option::is_none")] - client: Option, - - /// Messages that the user sent to the agent to initiate the turn. - input_messages: Vec, - - /// The last message sent by the assistant in the turn. - last_assistant_message: Option, - }, -} - -pub fn legacy_notify_json(payload: &HookPayload) -> Result { - match &payload.hook_event { - HookEvent::AfterAgent { event } => { - serde_json::to_string(&UserNotification::AgentTurnComplete { - thread_id: event.thread_id.to_string(), - turn_id: event.turn_id.clone(), - cwd: payload.cwd.display().to_string(), - client: payload.client.clone(), - input_messages: event.input_messages.clone(), - last_assistant_message: event.last_assistant_message.clone(), - }) - } - _ => Err(serde_json::Error::io(std::io::Error::other( - "legacy notify payload is only supported for after_agent", - ))), - } -} - -pub fn notify_hook(argv: Vec) -> Hook { - let argv = Arc::new(argv); - Hook { - name: "legacy_notify".to_string(), - func: Arc::new(move |payload: &HookPayload| { - let argv = Arc::clone(&argv); - Box::pin(async move { - let mut command = match command_from_argv(&argv) { - Some(command) => command, - None => return HookResult::Success, - }; - if let Ok(notify_payload) = legacy_notify_json(payload) { - command.arg(notify_payload); - } - - // Backwards-compat: match legacy notify behavior (argv + JSON arg, fire-and-forget). - command - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - - match command.spawn() { - Ok(_) => HookResult::Success, - Err(err) => HookResult::FailedContinue(err.into()), - } - }) - }), - } -} - -#[cfg(test)] -mod tests { - use anyhow::Result; - use codex_protocol::ThreadId; - use codex_utils_absolute_path::test_support::PathBufExt; - use codex_utils_absolute_path::test_support::test_path_buf; - use pretty_assertions::assert_eq; - use serde_json::Value; - use serde_json::json; - - use super::*; - - fn expected_notification_json() -> Value { - let cwd = test_path_buf("/Users/example/project"); - json!({ - "type": "agent-turn-complete", - "thread-id": "b5f6c1c2-1111-2222-3333-444455556666", - "turn-id": "12345", - "cwd": cwd.display().to_string(), - "client": "codex-tui", - "input-messages": ["Rename `foo` to `bar` and update the callsites."], - "last-assistant-message": "Rename complete and verified `cargo build` succeeds.", - }) - } - - #[test] - fn test_user_notification() -> Result<()> { - let notification = UserNotification::AgentTurnComplete { - thread_id: "b5f6c1c2-1111-2222-3333-444455556666".to_string(), - turn_id: "12345".to_string(), - cwd: test_path_buf("/Users/example/project") - .display() - .to_string(), - client: Some("codex-tui".to_string()), - input_messages: vec!["Rename `foo` to `bar` and update the callsites.".to_string()], - last_assistant_message: Some( - "Rename complete and verified `cargo build` succeeds.".to_string(), - ), - }; - let serialized = serde_json::to_string(¬ification)?; - let actual: Value = serde_json::from_str(&serialized)?; - assert_eq!(actual, expected_notification_json()); - Ok(()) - } - - #[test] - fn legacy_notify_json_matches_historical_wire_shape() -> Result<()> { - let payload = HookPayload { - session_id: ThreadId::new(), - cwd: test_path_buf("/Users/example/project").abs(), - client: Some("codex-tui".to_string()), - triggered_at: chrono::Utc::now(), - hook_event: HookEvent::AfterAgent { - event: crate::HookEventAfterAgent { - thread_id: ThreadId::from_string("b5f6c1c2-1111-2222-3333-444455556666") - .expect("valid thread id"), - turn_id: "12345".to_string(), - input_messages: vec![ - "Rename `foo` to `bar` and update the callsites.".to_string(), - ], - last_assistant_message: Some( - "Rename complete and verified `cargo build` succeeds.".to_string(), - ), - }, - }, - }; - - let serialized = legacy_notify_json(&payload)?; - let actual: Value = serde_json::from_str(&serialized)?; - assert_eq!(actual, expected_notification_json()); - - Ok(()) - } -} diff --git a/codex-rs/mcp-server/src/tool_handlers/mod.rs b/codex-rs/mcp-server/src/tool_handlers/mod.rs deleted file mode 100644 index 5863bdc288..0000000000 --- a/codex-rs/mcp-server/src/tool_handlers/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub(crate) mod create_conversation; -pub(crate) mod send_message; diff --git a/codex-rs/plugin/src/plugin_namespace.rs b/codex-rs/plugin/src/plugin_namespace.rs deleted file mode 100644 index 6688ae0469..0000000000 --- a/codex-rs/plugin/src/plugin_namespace.rs +++ /dev/null @@ -1,70 +0,0 @@ -//! Resolve plugin namespace from skill file paths by walking ancestors for `plugin.json`. - -use std::fs; -use std::path::Path; - -/// Relative path from a plugin root to its manifest file. -pub const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json"; - -#[derive(serde::Deserialize)] -#[serde(rename_all = "camelCase")] -struct RawPluginManifestName { - #[serde(default)] - name: String, -} - -fn plugin_manifest_name(plugin_root: &Path) -> Option { - let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH); - if !manifest_path.is_file() { - return None; - } - let contents = fs::read_to_string(&manifest_path).ok()?; - let RawPluginManifestName { name: raw_name } = serde_json::from_str(&contents).ok()?; - Some( - plugin_root - .file_name() - .and_then(|entry| entry.to_str()) - .filter(|_| raw_name.trim().is_empty()) - .unwrap_or(raw_name.as_str()) - .to_string(), - ) -} - -/// Returns the plugin manifest `name` for the nearest ancestor of `path` that contains a valid -/// plugin manifest (same `name` rules as full manifest loading in codex-core). -pub fn plugin_namespace_for_skill_path(path: &Path) -> Option { - for ancestor in path.ancestors() { - if let Some(name) = plugin_manifest_name(ancestor) { - return Some(name); - } - } - None -} - -#[cfg(test)] -mod tests { - use super::plugin_namespace_for_skill_path; - use std::fs; - use tempfile::tempdir; - - #[test] - fn uses_manifest_name() { - let tmp = tempdir().expect("tempdir"); - let plugin_root = tmp.path().join("plugins/sample"); - let skill_path = plugin_root.join("skills/search/SKILL.md"); - - fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir"); - fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("mkdir manifest"); - fs::write( - plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, - ) - .expect("write manifest"); - fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill"); - - assert_eq!( - plugin_namespace_for_skill_path(&skill_path), - Some("sample".to_string()) - ); - } -} diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index 876976f3c5..23a44c48e6 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -21,7 +21,6 @@ codex-network-proxy = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-image = { workspace = true } codex-utils-string = { workspace = true } -codex-utils-template = { workspace = true } encoding_rs = { workspace = true } globset = { workspace = true } icu_decimal = { workspace = true } @@ -60,5 +59,4 @@ tempfile = { workspace = true } [package.metadata.cargo-shear] # Required because: # `icu_provider`: contains a required `sync` feature for `icu_decimal` -# `strum`: as strum_macros in non-nightly builds -ignored = ["icu_provider", "strum"] +ignored = ["icu_provider"] diff --git a/codex-rs/windows-sandbox-rs/Cargo.toml b/codex-rs/windows-sandbox-rs/Cargo.toml index 4a71a952e7..31e563f4a8 100644 --- a/codex-rs/windows-sandbox-rs/Cargo.toml +++ b/codex-rs/windows-sandbox-rs/Cargo.toml @@ -12,11 +12,11 @@ doctest = false [[bin]] name = "codex-windows-sandbox-setup" -path = "src/bin/setup_main.rs" +path = "src/bin/setup_main/main.rs" [[bin]] name = "codex-command-runner" -path = "src/bin/command_runner.rs" +path = "src/bin/command_runner/main.rs" [lints] workspace = true @@ -96,6 +96,3 @@ pretty_assertions = { workspace = true } [build-dependencies] winres = "0.1" - -[package.metadata.cargo-shear] -ignored = ["codex-utils-pty", "tokio"] diff --git a/codex-rs/windows-sandbox-rs/src/acl.rs b/codex-rs/windows-sandbox-rs/src/acl.rs index f351dba190..68a17c9799 100644 --- a/codex-rs/windows-sandbox-rs/src/acl.rs +++ b/codex-rs/windows-sandbox-rs/src/acl.rs @@ -1,39 +1,40 @@ use crate::winutil::to_wide; -use anyhow::anyhow; use anyhow::Result; +use anyhow::anyhow; use std::ffi::c_void; use std::path::Path; use windows_sys::Win32::Foundation::CloseHandle; -use windows_sys::Win32::Foundation::LocalFree; use windows_sys::Win32::Foundation::ERROR_SUCCESS; use windows_sys::Win32::Foundation::HLOCAL; use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; +use windows_sys::Win32::Foundation::LocalFree; +use windows_sys::Win32::Security::ACCESS_ALLOWED_ACE; +use windows_sys::Win32::Security::ACE_HEADER; +use windows_sys::Win32::Security::ACL; +use windows_sys::Win32::Security::ACL_SIZE_INFORMATION; use windows_sys::Win32::Security::AclSizeInformation; +use windows_sys::Win32::Security::Authorization::EXPLICIT_ACCESS_W; use windows_sys::Win32::Security::Authorization::GetNamedSecurityInfoW; use windows_sys::Win32::Security::Authorization::GetSecurityInfo; use windows_sys::Win32::Security::Authorization::SetEntriesInAclW; use windows_sys::Win32::Security::Authorization::SetNamedSecurityInfoW; use windows_sys::Win32::Security::Authorization::SetSecurityInfo; -use windows_sys::Win32::Security::Authorization::EXPLICIT_ACCESS_W; use windows_sys::Win32::Security::Authorization::TRUSTEE_IS_SID; use windows_sys::Win32::Security::Authorization::TRUSTEE_IS_UNKNOWN; use windows_sys::Win32::Security::Authorization::TRUSTEE_W; +use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; use windows_sys::Win32::Security::EqualSid; +use windows_sys::Win32::Security::GENERIC_MAPPING; use windows_sys::Win32::Security::GetAce; use windows_sys::Win32::Security::GetAclInformation; use windows_sys::Win32::Security::MapGenericMask; -use windows_sys::Win32::Security::ACCESS_ALLOWED_ACE; -use windows_sys::Win32::Security::ACE_HEADER; -use windows_sys::Win32::Security::ACL; -use windows_sys::Win32::Security::ACL_SIZE_INFORMATION; -use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; -use windows_sys::Win32::Security::GENERIC_MAPPING; use windows_sys::Win32::Storage::FileSystem::CreateFileW; +use windows_sys::Win32::Storage::FileSystem::DELETE; use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS; use windows_sys::Win32::Storage::FileSystem::FILE_APPEND_DATA; use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_NORMAL; -use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS; use windows_sys::Win32::Storage::FileSystem::FILE_DELETE_CHILD; +use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_EXECUTE; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ; use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE; @@ -45,7 +46,6 @@ use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_DATA; use windows_sys::Win32::Storage::FileSystem::FILE_WRITE_EA; use windows_sys::Win32::Storage::FileSystem::OPEN_EXISTING; use windows_sys::Win32::Storage::FileSystem::READ_CONTROL; -use windows_sys::Win32::Storage::FileSystem::DELETE; const SE_KERNEL_OBJECT: u32 = 6; const INHERIT_ONLY_ACE: u8 = 0x08; const GENERIC_WRITE_MASK: u32 = 0x4000_0000; @@ -259,12 +259,8 @@ pub unsafe fn dacl_has_write_deny_for_sid(p_dacl: *mut ACL, psid: *mut c_void) - false } -const WRITE_ALLOW_MASK: u32 = FILE_GENERIC_READ - | FILE_GENERIC_WRITE - | FILE_GENERIC_EXECUTE - | DELETE - | FILE_DELETE_CHILD; - +const WRITE_ALLOW_MASK: u32 = + FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE | FILE_DELETE_CHILD; unsafe fn ensure_allow_mask_aces_with_inheritance_impl( path: &Path, @@ -275,12 +271,7 @@ unsafe fn ensure_allow_mask_aces_with_inheritance_impl( let (p_dacl, p_sd) = fetch_dacl_handle(path)?; let mut entries: Vec = Vec::new(); for sid in sids { - if dacl_mask_allows( - p_dacl, - &[*sid], - allow_mask, - /*require_all_bits*/ true, - ) { + if dacl_mask_allows(p_dacl, &[*sid], allow_mask, /*require_all_bits*/ true) { continue; } entries.push(EXPLICIT_ACCESS_W { diff --git a/codex-rs/windows-sandbox-rs/src/allow.rs b/codex-rs/windows-sandbox-rs/src/allow.rs index 273dc8c4f2..5b6d0d617a 100644 --- a/codex-rs/windows-sandbox-rs/src/allow.rs +++ b/codex-rs/windows-sandbox-rs/src/allow.rs @@ -117,12 +117,16 @@ mod tests { let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &HashMap::new()); - assert!(paths - .allow - .contains(&dunce::canonicalize(&command_cwd).unwrap())); - assert!(paths - .allow - .contains(&dunce::canonicalize(&extra_root).unwrap())); + assert!( + paths + .allow + .contains(&dunce::canonicalize(&command_cwd).unwrap()) + ); + assert!( + paths + .allow + .contains(&dunce::canonicalize(&extra_root).unwrap()) + ); assert!(paths.deny.is_empty(), "no deny paths expected"); } @@ -145,12 +149,16 @@ mod tests { let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &env_map); - assert!(paths - .allow - .contains(&dunce::canonicalize(&command_cwd).unwrap())); - assert!(!paths - .allow - .contains(&dunce::canonicalize(&temp_dir).unwrap())); + assert!( + paths + .allow + .contains(&dunce::canonicalize(&command_cwd).unwrap()) + ); + assert!( + !paths + .allow + .contains(&dunce::canonicalize(&temp_dir).unwrap()) + ); assert!(paths.deny.is_empty(), "no deny paths expected"); } @@ -253,6 +261,9 @@ mod tests { let paths = compute_allow_paths(&policy, &command_cwd, &command_cwd, &HashMap::new()); assert_eq!(paths.allow.len(), 1); - assert!(paths.deny.is_empty(), "no deny when protected dirs are absent"); + assert!( + paths.deny.is_empty(), + "no deny when protected dirs are absent" + ); } } diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index 9ee371ce68..c45e3341b7 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -2,16 +2,17 @@ use crate::acl::add_deny_write_ace; use crate::acl::path_mask_allows; use crate::cap::cap_sid_file; use crate::cap::load_or_create_cap_sids; -use crate::logging::{debug_log, log_note}; +use crate::logging::debug_log; +use crate::logging::log_note; use crate::path_normalization::canonical_path_key; use crate::policy::SandboxPolicy; use crate::token::convert_string_sid_to_sid; use crate::token::world_sid; -use anyhow::anyhow; use anyhow::Result; +use anyhow::anyhow; use std::collections::HashSet; -use std::ffi::c_void; use std::ffi::OsStr; +use std::ffi::c_void; use std::path::Path; use std::path::PathBuf; use std::time::Duration; @@ -81,7 +82,12 @@ unsafe fn path_has_world_write_allow(path: &Path) -> Result { let mut world = world_sid()?; let psid_world = world.as_mut_ptr() as *mut c_void; let write_mask = FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES; - path_mask_allows(path, &[psid_world], write_mask, /*require_all_bits*/ false) + path_mask_allows( + path, + &[psid_world], + write_mask, + /*require_all_bits*/ false, + ) } pub fn audit_everyone_writable( @@ -262,9 +268,8 @@ pub fn apply_capability_denies_for_world_writable( (sid, roots) } SandboxPolicy::ReadOnly { .. } => ( - unsafe { convert_string_sid_to_sid(&caps.readonly) }.ok_or_else(|| { - anyhow!("ConvertStringSidToSidW failed for readonly capability") - })?, + unsafe { convert_string_sid_to_sid(&caps.readonly) } + .ok_or_else(|| anyhow!("ConvertStringSidToSidW failed for readonly capability"))?, Vec::new(), ), SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { diff --git a/codex-rs/windows-sandbox-rs/src/bin/command_runner.rs b/codex-rs/windows-sandbox-rs/src/bin/command_runner/main.rs similarity index 80% rename from codex-rs/windows-sandbox-rs/src/bin/command_runner.rs rename to codex-rs/windows-sandbox-rs/src/bin/command_runner/main.rs index db08324908..9fe70c8750 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/command_runner.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/command_runner/main.rs @@ -1,4 +1,4 @@ -#[path = "../elevated/command_runner_win.rs"] +#[cfg(target_os = "windows")] mod win; #[cfg(target_os = "windows")] diff --git a/codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs b/codex-rs/windows-sandbox-rs/src/bin/command_runner/win.rs similarity index 96% rename from codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs rename to codex-rs/windows-sandbox-rs/src/bin/command_runner/win.rs index 80e67044e8..b161dd57b8 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated/command_runner_win.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/command_runner/win.rs @@ -7,9 +7,10 @@ //! accepts stdin/terminate frames, and emits a final exit frame. The legacy restricted‑token //! path spawns the child directly and does not use this runner. -#![cfg(target_os = "windows")] #![allow(unsafe_op_in_unsafe_fn)] +mod cwd_junction; + use anyhow::Context; use anyhow::Result; use codex_windows_sandbox::ErrorPayload; @@ -40,6 +41,7 @@ use codex_windows_sandbox::read_handle_loop; use codex_windows_sandbox::spawn_process_with_pipes; use codex_windows_sandbox::to_wide; use codex_windows_sandbox::write_frame; +use std::ffi::OsStr; use std::fs::File; use std::os::windows::io::FromRawHandle; use std::path::Path; @@ -48,6 +50,7 @@ use std::ptr; use std::sync::Arc; use std::sync::Mutex as StdMutex; use windows_sys::Win32::Foundation::CloseHandle; +use windows_sys::Win32::Foundation::ERROR_FILE_NOT_FOUND; use windows_sys::Win32::Foundation::GetLastError; use windows_sys::Win32::Foundation::HANDLE; use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; @@ -66,17 +69,13 @@ use windows_sys::Win32::System::JobObjects::SetInformationJobObject; use windows_sys::Win32::System::Threading::GetExitCodeProcess; use windows_sys::Win32::System::Threading::GetProcessId; use windows_sys::Win32::System::Threading::INFINITE; +use windows_sys::Win32::System::Threading::MUTEX_ALL_ACCESS; +use windows_sys::Win32::System::Threading::OpenMutexW; use windows_sys::Win32::System::Threading::PROCESS_INFORMATION; use windows_sys::Win32::System::Threading::TerminateProcess; use windows_sys::Win32::System::Threading::WaitForSingleObject; -#[path = "cwd_junction.rs"] -mod cwd_junction; - -#[allow(dead_code)] -#[path = "../read_acl_mutex.rs"] -mod read_acl_mutex; - +const READ_ACL_MUTEX_NAME: &str = "Local\\CodexSandboxReadAcl"; const WAIT_TIMEOUT: u32 = 0x0000_0102; struct IpcSpawnedProcess { @@ -196,9 +195,25 @@ fn read_spawn_request(reader: &mut File) -> Result { } } +fn read_acl_mutex_exists() -> Result { + let name = to_wide(OsStr::new(READ_ACL_MUTEX_NAME)); + let handle = unsafe { OpenMutexW(MUTEX_ALL_ACCESS, 0, name.as_ptr()) }; + if handle == 0 { + let err = unsafe { GetLastError() }; + if err == ERROR_FILE_NOT_FOUND { + return Ok(false); + } + return Err(anyhow::anyhow!("OpenMutexW failed: {err}")); + } + unsafe { + CloseHandle(handle); + } + Ok(true) +} + /// Pick an effective CWD, using a junction if the ACL helper is active. fn effective_cwd(req_cwd: &Path, log_dir: Option<&Path>) -> PathBuf { - let use_junction = match read_acl_mutex::read_acl_mutex_exists() { + let use_junction = match read_acl_mutex_exists() { Ok(exists) => exists, Err(err) => { log_note( diff --git a/codex-rs/windows-sandbox-rs/src/elevated/cwd_junction.rs b/codex-rs/windows-sandbox-rs/src/bin/command_runner/win/cwd_junction.rs similarity index 99% rename from codex-rs/windows-sandbox-rs/src/elevated/cwd_junction.rs rename to codex-rs/windows-sandbox-rs/src/bin/command_runner/win/cwd_junction.rs index 4765686b34..7ddd391c34 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated/cwd_junction.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/command_runner/win/cwd_junction.rs @@ -1,5 +1,3 @@ -#![cfg(target_os = "windows")] - use codex_windows_sandbox::log_note; use std::collections::hash_map::DefaultHasher; use std::hash::Hash; diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/main.rs similarity index 85% rename from codex-rs/windows-sandbox-rs/src/bin/setup_main.rs rename to codex-rs/windows-sandbox-rs/src/bin/setup_main/main.rs index c3bc5724f3..262460775e 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/main.rs @@ -1,4 +1,4 @@ -#[path = "../setup_main_win.rs"] +#[cfg(target_os = "windows")] mod win; #[cfg(target_os = "windows")] diff --git a/codex-rs/windows-sandbox-rs/src/setup_main_win.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs similarity index 99% rename from codex-rs/windows-sandbox-rs/src/setup_main_win.rs rename to codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs index 5df1e37a07..549eb2426d 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_main_win.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs @@ -1,6 +1,5 @@ -#![cfg(target_os = "windows")] - mod firewall; +mod read_acl_mutex; use anyhow::Context; use anyhow::Result; @@ -67,9 +66,7 @@ use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_WRITE; const DENY_ACCESS: i32 = 3; -mod read_acl_mutex; mod sandbox_users; -#[path = "setup_runtime_bin.rs"] mod setup_runtime_bin; use read_acl_mutex::acquire_read_acl_mutex; use read_acl_mutex::read_acl_mutex_exists; diff --git a/codex-rs/windows-sandbox-rs/src/firewall.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs similarity index 99% rename from codex-rs/windows-sandbox-rs/src/firewall.rs rename to codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs index b5dfb2ef49..caa1780437 100644 --- a/codex-rs/windows-sandbox-rs/src/firewall.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs @@ -1,5 +1,3 @@ -#![cfg(target_os = "windows")] - use anyhow::Result; use std::fs::File; use std::io::Write; diff --git a/codex-rs/windows-sandbox-rs/src/read_acl_mutex.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/read_acl_mutex.rs similarity index 89% rename from codex-rs/windows-sandbox-rs/src/read_acl_mutex.rs rename to codex-rs/windows-sandbox-rs/src/bin/setup_main/win/read_acl_mutex.rs index d77129467a..a96b6a5024 100644 --- a/codex-rs/windows-sandbox-rs/src/read_acl_mutex.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/read_acl_mutex.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use codex_windows_sandbox::to_wide; use std::ffi::OsStr; use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::Foundation::ERROR_ALREADY_EXISTS; @@ -10,11 +11,9 @@ use windows_sys::Win32::System::Threading::MUTEX_ALL_ACCESS; use windows_sys::Win32::System::Threading::OpenMutexW; use windows_sys::Win32::System::Threading::ReleaseMutex; -use super::to_wide; - const READ_ACL_MUTEX_NAME: &str = "Local\\CodexSandboxReadAcl"; -pub struct ReadAclMutexGuard { +pub(super) struct ReadAclMutexGuard { handle: HANDLE, } @@ -27,7 +26,7 @@ impl Drop for ReadAclMutexGuard { } } -pub fn read_acl_mutex_exists() -> Result { +pub(super) fn read_acl_mutex_exists() -> Result { let name = to_wide(OsStr::new(READ_ACL_MUTEX_NAME)); let handle = unsafe { OpenMutexW(MUTEX_ALL_ACCESS, 0, name.as_ptr()) }; if handle == 0 { @@ -43,7 +42,7 @@ pub fn read_acl_mutex_exists() -> Result { Ok(true) } -pub fn acquire_read_acl_mutex() -> Result> { +pub(super) fn acquire_read_acl_mutex() -> Result> { let name = to_wide(OsStr::new(READ_ACL_MUTEX_NAME)); let handle = unsafe { CreateMutexW(std::ptr::null_mut(), 1, name.as_ptr()) }; if handle == 0 { diff --git a/codex-rs/windows-sandbox-rs/src/sandbox_users.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs similarity index 99% rename from codex-rs/windows-sandbox-rs/src/sandbox_users.rs rename to codex-rs/windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs index 13460b9444..de76b2413b 100644 --- a/codex-rs/windows-sandbox-rs/src/sandbox_users.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs @@ -1,5 +1,3 @@ -#![cfg(target_os = "windows")] - use anyhow::Result; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64; diff --git a/codex-rs/windows-sandbox-rs/src/setup_runtime_bin.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs similarity index 100% rename from codex-rs/windows-sandbox-rs/src/setup_runtime_bin.rs rename to codex-rs/windows-sandbox-rs/src/bin/setup_main/win/setup_runtime_bin.rs diff --git a/codex-rs/windows-sandbox-rs/src/cap.rs b/codex-rs/windows-sandbox-rs/src/cap.rs index 79f3b2f266..85f7e2c9d6 100644 --- a/codex-rs/windows-sandbox-rs/src/cap.rs +++ b/codex-rs/windows-sandbox-rs/src/cap.rs @@ -1,15 +1,15 @@ +use crate::path_normalization::canonical_path_key; use anyhow::Context; use anyhow::Result; -use rand::rngs::SmallRng; use rand::RngCore; use rand::SeedableRng; +use rand::rngs::SmallRng; use serde::Deserialize; use serde::Serialize; use std::collections::HashMap; use std::fs; use std::path::Path; use std::path::PathBuf; -use crate::path_normalization::canonical_path_key; #[derive(Serialize, Deserialize, Clone, Debug)] pub struct CapSids { @@ -106,7 +106,12 @@ mod tests { std::fs::create_dir_all(&workspace).expect("create workspace root"); let canonical = dunce::canonicalize(&workspace).expect("canonical workspace root"); - let alt_spelling = PathBuf::from(canonical.to_string_lossy().replace('\\', "/").to_ascii_uppercase()); + let alt_spelling = PathBuf::from( + canonical + .to_string_lossy() + .replace('\\', "/") + .to_ascii_uppercase(), + ); let first_sid = workspace_cap_sid_for_cwd(&codex_home, canonical.as_path()).expect("first sid"); diff --git a/codex-rs/windows-sandbox-rs/src/desktop.rs b/codex-rs/windows-sandbox-rs/src/desktop.rs index d2aa129b92..918278828d 100644 --- a/codex-rs/windows-sandbox-rs/src/desktop.rs +++ b/codex-rs/windows-sandbox-rs/src/desktop.rs @@ -7,13 +7,13 @@ use anyhow::Result; use rand::Rng; use rand::SeedableRng; use rand::rngs::SmallRng; -use std::path::Path; use std::ffi::c_void; +use std::path::Path; use std::ptr; use windows_sys::Win32::Foundation::CloseHandle; +use windows_sys::Win32::Foundation::ERROR_SUCCESS; use windows_sys::Win32::Foundation::GetLastError; use windows_sys::Win32::Foundation::HLOCAL; -use windows_sys::Win32::Foundation::ERROR_SUCCESS; use windows_sys::Win32::Foundation::LocalFree; use windows_sys::Win32::Security::Authorization::EXPLICIT_ACCESS_W; use windows_sys::Win32::Security::Authorization::GRANT_ACCESS; @@ -25,16 +25,16 @@ use windows_sys::Win32::Security::Authorization::TRUSTEE_IS_UNKNOWN; use windows_sys::Win32::Security::Authorization::TRUSTEE_W; use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; use windows_sys::Win32::System::StationsAndDesktops::CloseDesktop; -use windows_sys::Win32::System::StationsAndDesktops::DESKTOP_CREATEMENU; use windows_sys::Win32::System::StationsAndDesktops::CreateDesktopW; +use windows_sys::Win32::System::StationsAndDesktops::DESKTOP_CREATEMENU; use windows_sys::Win32::System::StationsAndDesktops::DESKTOP_CREATEWINDOW; use windows_sys::Win32::System::StationsAndDesktops::DESKTOP_DELETE; use windows_sys::Win32::System::StationsAndDesktops::DESKTOP_ENUMERATE; use windows_sys::Win32::System::StationsAndDesktops::DESKTOP_HOOKCONTROL; use windows_sys::Win32::System::StationsAndDesktops::DESKTOP_JOURNALPLAYBACK; use windows_sys::Win32::System::StationsAndDesktops::DESKTOP_JOURNALRECORD; -use windows_sys::Win32::System::StationsAndDesktops::DESKTOP_READOBJECTS; use windows_sys::Win32::System::StationsAndDesktops::DESKTOP_READ_CONTROL; +use windows_sys::Win32::System::StationsAndDesktops::DESKTOP_READOBJECTS; use windows_sys::Win32::System::StationsAndDesktops::DESKTOP_SWITCHDESKTOP; use windows_sys::Win32::System::StationsAndDesktops::DESKTOP_WRITE_DAC; use windows_sys::Win32::System::StationsAndDesktops::DESKTOP_WRITE_OWNER; diff --git a/codex-rs/windows-sandbox-rs/src/dpapi.rs b/codex-rs/windows-sandbox-rs/src/dpapi.rs index c4dcc79fa6..3a90704127 100644 --- a/codex-rs/windows-sandbox-rs/src/dpapi.rs +++ b/codex-rs/windows-sandbox-rs/src/dpapi.rs @@ -1,13 +1,13 @@ -use anyhow::anyhow; use anyhow::Result; +use anyhow::anyhow; use windows_sys::Win32::Foundation::GetLastError; -use windows_sys::Win32::Foundation::LocalFree; use windows_sys::Win32::Foundation::HLOCAL; -use windows_sys::Win32::Security::Cryptography::CryptProtectData; -use windows_sys::Win32::Security::Cryptography::CryptUnprotectData; +use windows_sys::Win32::Foundation::LocalFree; +use windows_sys::Win32::Security::Cryptography::CRYPT_INTEGER_BLOB; use windows_sys::Win32::Security::Cryptography::CRYPTPROTECT_LOCAL_MACHINE; use windows_sys::Win32::Security::Cryptography::CRYPTPROTECT_UI_FORBIDDEN; -use windows_sys::Win32::Security::Cryptography::CRYPT_INTEGER_BLOB; +use windows_sys::Win32::Security::Cryptography::CryptProtectData; +use windows_sys::Win32::Security::Cryptography::CryptUnprotectData; fn make_blob(data: &[u8]) -> CRYPT_INTEGER_BLOB { CRYPT_INTEGER_BLOB { diff --git a/codex-rs/windows-sandbox-rs/src/elevated/mod.rs b/codex-rs/windows-sandbox-rs/src/elevated/mod.rs new file mode 100644 index 0000000000..3c8084bb62 --- /dev/null +++ b/codex-rs/windows-sandbox-rs/src/elevated/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod ipc_framed; +pub(crate) mod runner_client; +pub(crate) mod runner_pipe; diff --git a/codex-rs/windows-sandbox-rs/src/env.rs b/codex-rs/windows-sandbox-rs/src/env.rs index 93275e1abd..bdf7a76ab2 100644 --- a/codex-rs/windows-sandbox-rs/src/env.rs +++ b/codex-rs/windows-sandbox-rs/src/env.rs @@ -1,10 +1,13 @@ -use anyhow::{anyhow, Result}; +use anyhow::Result; +use anyhow::anyhow; use dirs_next::home_dir; use std::collections::HashMap; use std::env; -use std::fs::{self, File}; +use std::fs::File; +use std::fs::{self}; use std::io::Write; -use std::path::{Path, PathBuf}; +use std::path::Path; +use std::path::PathBuf; pub fn normalize_null_device_env(env_map: &mut HashMap) { let keys: Vec = env_map.keys().cloned().collect(); diff --git a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs index b7d00e1dd9..8f0b5cb83b 100644 --- a/codex-rs/windows-sandbox-rs/src/helper_materialization.rs +++ b/codex-rs/windows-sandbox-rs/src/helper_materialization.rs @@ -1,6 +1,6 @@ -use anyhow::anyhow; use anyhow::Context; use anyhow::Result; +use anyhow::anyhow; use std::collections::HashMap; use std::fs; use std::io::Write; @@ -89,10 +89,7 @@ pub(crate) fn resolve_helper_for_launch( } } -pub fn resolve_current_exe_for_launch( - codex_home: &Path, - fallback_executable: &str, -) -> PathBuf { +pub fn resolve_current_exe_for_launch(codex_home: &Path, fallback_executable: &str) -> PathBuf { let source = match std::env::current_exe() { Ok(path) => path, Err(_) => return PathBuf::from(fallback_executable), @@ -242,11 +239,7 @@ fn dev_build_suffix(source: &Path) -> Result { let duration = modified .duration_since(UNIX_EPOCH) .with_context(|| format!("convert helper source mtime {}", source.display()))?; - Ok(format!( - "{}-{:x}", - metadata.len(), - duration.as_secs(), - )) + Ok(format!("{}-{:x}", metadata.len(), duration.as_secs(),)) } fn copy_from_source_if_needed(source: &Path, destination: &Path) -> Result { @@ -254,9 +247,12 @@ fn copy_from_source_if_needed(source: &Path, destination: &Path) -> Result Result { Ok(meta) => meta, Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false), Err(err) => { - return Err(err) - .with_context(|| format!("read helper destination metadata {}", destination.display())); + return Err(err).with_context(|| { + format!("read helper destination metadata {}", destination.display()) + }); } }; @@ -348,16 +345,16 @@ fn destination_is_fresh(source: &Path, destination: &Path) -> Result { #[cfg(test)] mod tests { - use super::copy_from_source_if_needed; use super::CopyOutcome; - use super::dev_build_suffix; + use super::DEV_BUILD_VERSION_SENTINEL; + use super::HelperExecutable; + use super::RESOURCES_DIRNAME; + use super::copy_from_source_if_needed; use super::destination_is_fresh; + use super::dev_build_suffix; use super::helper_bin_dir; use super::helper_version_suffix; use super::materialized_file_name; - use super::HelperExecutable; - use super::DEV_BUILD_VERSION_SENTINEL; - use super::RESOURCES_DIRNAME; use super::source_path_for_exe; use pretty_assertions::assert_eq; use std::fs; @@ -376,7 +373,10 @@ mod tests { let outcome = copy_from_source_if_needed(&source, &destination).expect("copy helper"); assert_eq!(CopyOutcome::ReCopied, outcome); - assert_eq!(b"runner-v1".as_slice(), fs::read(&destination).expect("read destination")); + assert_eq!( + b"runner-v1".as_slice(), + fs::read(&destination).expect("read destination") + ); } #[test] @@ -403,11 +403,13 @@ mod tests { fs::write(&source, b"runner-v1").expect("write source"); copy_from_source_if_needed(&source, &destination).expect("initial copy"); - let outcome = - copy_from_source_if_needed(&source, &destination).expect("revalidate helper"); + let outcome = copy_from_source_if_needed(&source, &destination).expect("revalidate helper"); assert_eq!(CopyOutcome::Reused, outcome); - assert_eq!(b"runner-v1".as_slice(), fs::read(&destination).expect("read destination")); + assert_eq!( + b"runner-v1".as_slice(), + fs::read(&destination).expect("read destination") + ); } #[test] @@ -429,8 +431,10 @@ mod tests { let runner_source = source_dir.join("codex-command-runner.exe"); fs::write(&runner_source, b"runner").expect("runner"); let runner_suffix = helper_version_suffix(&runner_source).expect("runner suffix"); - let runner_destination = helper_bin_dir(&codex_home) - .join(materialized_file_name(HelperExecutable::CommandRunner, &runner_suffix)); + let runner_destination = helper_bin_dir(&codex_home).join(materialized_file_name( + HelperExecutable::CommandRunner, + &runner_suffix, + )); let runner_outcome = copy_from_source_if_needed(&runner_source, &runner_destination).expect("runner copy"); @@ -453,8 +457,8 @@ mod tests { fs::write(&exe, b"codex").expect("write exe"); fs::write(&helper, b"runner").expect("write helper"); - let resolved = - source_path_for_exe(&exe, /*file_name*/ "codex-command-runner.exe").expect("helper path"); + let resolved = source_path_for_exe(&exe, /*file_name*/ "codex-command-runner.exe") + .expect("helper path"); assert_eq!(resolved, helper); } @@ -472,8 +476,8 @@ mod tests { fs::write(&sibling_helper, b"sibling runner").expect("write sibling helper"); fs::write(&resource_helper, b"resource runner").expect("write resource helper"); - let resolved = - source_path_for_exe(&exe, /*file_name*/ "codex-command-runner.exe").expect("helper path"); + let resolved = source_path_for_exe(&exe, /*file_name*/ "codex-command-runner.exe") + .expect("helper path"); assert_eq!(resolved, sibling_helper); } diff --git a/codex-rs/windows-sandbox-rs/src/hide_users.rs b/codex-rs/windows-sandbox-rs/src/hide_users.rs index 10964d1c13..b919440022 100644 --- a/codex-rs/windows-sandbox-rs/src/hide_users.rs +++ b/codex-rs/windows-sandbox-rs/src/hide_users.rs @@ -1,5 +1,3 @@ -#![cfg(target_os = "windows")] - use crate::logging::log_note; use crate::winutil::format_last_error; use crate::winutil::to_wide; @@ -8,19 +6,19 @@ use std::ffi::OsStr; use std::path::Path; use std::path::PathBuf; use windows_sys::Win32::Foundation::GetLastError; -use windows_sys::Win32::Storage::FileSystem::GetFileAttributesW; -use windows_sys::Win32::Storage::FileSystem::SetFileAttributesW; use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_HIDDEN; use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_SYSTEM; +use windows_sys::Win32::Storage::FileSystem::GetFileAttributesW; use windows_sys::Win32::Storage::FileSystem::INVALID_FILE_ATTRIBUTES; -use windows_sys::Win32::System::Registry::RegCloseKey; -use windows_sys::Win32::System::Registry::RegCreateKeyExW; -use windows_sys::Win32::System::Registry::RegSetValueExW; +use windows_sys::Win32::Storage::FileSystem::SetFileAttributesW; use windows_sys::Win32::System::Registry::HKEY; use windows_sys::Win32::System::Registry::HKEY_LOCAL_MACHINE; use windows_sys::Win32::System::Registry::KEY_WRITE; use windows_sys::Win32::System::Registry::REG_DWORD; use windows_sys::Win32::System::Registry::REG_OPTION_NON_VOLATILE; +use windows_sys::Win32::System::Registry::RegCloseKey; +use windows_sys::Win32::System::Registry::RegCreateKeyExW; +use windows_sys::Win32::System::Registry::RegSetValueExW; const USERLIST_KEY_PATH: &str = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\SpecialAccounts\UserList"; diff --git a/codex-rs/windows-sandbox-rs/src/identity.rs b/codex-rs/windows-sandbox-rs/src/identity.rs index 84e72341e2..6e2d392b3a 100644 --- a/codex-rs/windows-sandbox-rs/src/identity.rs +++ b/codex-rs/windows-sandbox-rs/src/identity.rs @@ -1,6 +1,10 @@ use crate::dpapi; use crate::logging::debug_log; use crate::policy::SandboxPolicy; +use crate::setup::SandboxNetworkIdentity; +use crate::setup::SandboxUserRecord; +use crate::setup::SandboxUsersFile; +use crate::setup::SetupMarker; use crate::setup::gather_read_roots; use crate::setup::gather_write_roots; use crate::setup::offline_proxy_settings_from_env; @@ -8,15 +12,11 @@ use crate::setup::run_elevated_setup; use crate::setup::run_setup_refresh_with_overrides; use crate::setup::sandbox_users_path; use crate::setup::setup_marker_path; -use crate::setup::SandboxNetworkIdentity; -use crate::setup::SandboxUserRecord; -use crate::setup::SandboxUsersFile; -use crate::setup::SetupMarker; -use anyhow::anyhow; use anyhow::Context; use anyhow::Result; -use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; +use anyhow::anyhow; use base64::Engine; +use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use std::collections::HashMap; use std::fs; use std::path::Path; @@ -150,8 +150,7 @@ pub fn require_logon_sandbox_creds( .map(<[PathBuf]>::to_vec) .unwrap_or_else(|| gather_write_roots(policy, policy_cwd, command_cwd, env_map)); let network_identity = SandboxNetworkIdentity::from_policy(policy, proxy_enforced); - let desired_offline_proxy_settings = - offline_proxy_settings_from_env(env_map, network_identity); + let desired_offline_proxy_settings = offline_proxy_settings_from_env(env_map, network_identity); // NOTE: Do not add CODEX_HOME/.sandbox to `needed_write`; it must remain non-writable by the // restricted capability token. The setup helper's `lock_sandbox_dir` is responsible for // granting the sandbox group access to this directory without granting the capability SID. @@ -167,8 +166,9 @@ pub fn require_logon_sandbox_creds( } else { let selected = select_identity(network_identity, codex_home)?; if selected.is_none() { - setup_reason = - Some("sandbox users missing or incompatible with marker version".to_string()); + setup_reason = Some( + "sandbox users missing or incompatible with marker version".to_string(), + ); } selected } diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 522f8926d5..6d1d8b2135 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -5,75 +5,70 @@ #[cfg(any(target_os = "windows", test))] mod ssh_config_dependencies; -macro_rules! windows_modules { - ($($name:ident),+ $(,)?) => { - $(#[cfg(target_os = "windows")] mod $name;)+ - }; -} - -windows_modules!( - acl, - allow, - audit, - cap, - desktop, - dpapi, - env, - helper_materialization, - hide_users, - identity, - logging, - path_normalization, - policy, - process, - token, - wfp, - wfp_setup, - winutil, - workspace_acl -); +#[cfg(target_os = "windows")] +mod acl; +#[cfg(target_os = "windows")] +mod allow; +#[cfg(target_os = "windows")] +mod audit; +#[cfg(target_os = "windows")] +mod cap; +#[cfg(target_os = "windows")] +mod desktop; +#[cfg(target_os = "windows")] +mod dpapi; +#[cfg(target_os = "windows")] +mod elevated; +#[cfg(target_os = "windows")] +mod elevated_impl; +#[cfg(target_os = "windows")] +mod env; +#[cfg(target_os = "windows")] +mod helper_materialization; +#[cfg(target_os = "windows")] +mod hide_users; +#[cfg(target_os = "windows")] +mod identity; +#[cfg(target_os = "windows")] +mod logging; +#[cfg(target_os = "windows")] +mod path_normalization; +#[cfg(target_os = "windows")] +mod policy; +#[cfg(target_os = "windows")] +mod proc_thread_attr; +#[cfg(target_os = "windows")] +mod process; +#[cfg(target_os = "windows")] +mod sandbox_utils; +#[cfg(target_os = "windows")] +mod setup; +#[cfg(target_os = "windows")] +mod setup_error; +#[cfg(target_os = "windows")] +mod spawn_prep; +#[cfg(target_os = "windows")] +mod token; +#[cfg(target_os = "windows")] +mod unified_exec; +#[cfg(target_os = "windows")] +mod wfp; +#[cfg(target_os = "windows")] +mod wfp_setup; +#[cfg(target_os = "windows")] +mod winutil; +#[cfg(target_os = "windows")] +mod workspace_acl; #[cfg(target_os = "windows")] -#[path = "conpty/mod.rs"] mod conpty; #[cfg(target_os = "windows")] -#[path = "proc_thread_attr.rs"] -mod proc_thread_attr; - +pub(crate) use elevated::ipc_framed; #[cfg(target_os = "windows")] -#[path = "elevated/ipc_framed.rs"] -pub(crate) mod ipc_framed; - +pub(crate) use elevated::runner_client; #[cfg(target_os = "windows")] -#[path = "setup_orchestrator.rs"] -mod setup; - -#[cfg(target_os = "windows")] -mod elevated_impl; - -#[cfg(target_os = "windows")] -#[path = "elevated/runner_pipe.rs"] -mod runner_pipe; - -#[cfg(target_os = "windows")] -#[path = "elevated/runner_client.rs"] -mod runner_client; - -#[cfg(target_os = "windows")] -mod setup_error; - -#[cfg(target_os = "windows")] -#[path = "sandbox_utils.rs"] -mod sandbox_utils; - -#[cfg(target_os = "windows")] -#[path = "spawn_prep.rs"] -mod spawn_prep; - -#[cfg(target_os = "windows")] -#[path = "unified_exec/session.rs"] -mod session; +pub(crate) use elevated::runner_pipe; #[cfg(target_os = "windows")] pub use acl::add_deny_write_ace; @@ -169,10 +164,6 @@ pub use process::read_handle_loop; #[cfg(target_os = "windows")] pub use process::spawn_process_with_pipes; #[cfg(target_os = "windows")] -pub use session::spawn_windows_sandbox_session_elevated; -#[cfg(target_os = "windows")] -pub use session::spawn_windows_sandbox_session_legacy; -#[cfg(target_os = "windows")] pub use setup::SETUP_VERSION; #[cfg(target_os = "windows")] pub use setup::SandboxSetupRequest; @@ -222,6 +213,10 @@ pub use token::create_workspace_write_token_with_caps_from; #[cfg(target_os = "windows")] pub use token::get_current_token_for_restriction; #[cfg(target_os = "windows")] +pub use unified_exec::spawn_windows_sandbox_session_elevated; +#[cfg(target_os = "windows")] +pub use unified_exec::spawn_windows_sandbox_session_legacy; +#[cfg(target_os = "windows")] pub use wfp::install_wfp_filters_for_account; #[cfg(target_os = "windows")] pub use wfp_setup::install_wfp_filters; diff --git a/codex-rs/windows-sandbox-rs/src/path_normalization.rs b/codex-rs/windows-sandbox-rs/src/path_normalization.rs index fe6a932306..1735b745a2 100644 --- a/codex-rs/windows-sandbox-rs/src/path_normalization.rs +++ b/codex-rs/windows-sandbox-rs/src/path_normalization.rs @@ -23,6 +23,9 @@ mod tests { let windows_style = Path::new(r"C:\Users\Dev\Repo"); let slash_style = Path::new("c:/users/dev/repo"); - assert_eq!(canonical_path_key(windows_style), canonical_path_key(slash_style)); + assert_eq!( + canonical_path_key(windows_style), + canonical_path_key(slash_style) + ); } } diff --git a/codex-rs/windows-sandbox-rs/src/policy.rs b/codex-rs/windows-sandbox-rs/src/policy.rs index 3cee37cdf7..cfaa9689ee 100644 --- a/codex-rs/windows-sandbox-rs/src/policy.rs +++ b/codex-rs/windows-sandbox-rs/src/policy.rs @@ -5,9 +5,9 @@ pub fn parse_policy(value: &str) -> Result { match value { "read-only" => Ok(SandboxPolicy::new_read_only_policy()), "workspace-write" => Ok(SandboxPolicy::new_workspace_write_policy()), - "danger-full-access" | "external-sandbox" => anyhow::bail!( - "DangerFullAccess and ExternalSandbox are not supported for sandboxing" - ), + "danger-full-access" | "external-sandbox" => { + anyhow::bail!("DangerFullAccess and ExternalSandbox are not supported for sandboxing") + } other => { let parsed: SandboxPolicy = serde_json::from_str(other)?; if matches!( @@ -31,23 +31,24 @@ mod tests { #[test] fn rejects_external_sandbox_preset() { let err = parse_policy("external-sandbox").unwrap_err(); - assert!(err - .to_string() - .contains("DangerFullAccess and ExternalSandbox are not supported")); + assert!( + err.to_string() + .contains("DangerFullAccess and ExternalSandbox are not supported") + ); } #[test] fn rejects_external_sandbox_json() { - let payload = serde_json::to_string( - &codex_protocol::protocol::SandboxPolicy::ExternalSandbox { + let payload = + serde_json::to_string(&codex_protocol::protocol::SandboxPolicy::ExternalSandbox { network_access: codex_protocol::protocol::NetworkAccess::Enabled, - }, - ) - .unwrap(); + }) + .unwrap(); let err = parse_policy(&payload).unwrap_err(); - assert!(err - .to_string() - .contains("DangerFullAccess and ExternalSandbox are not supported")); + assert!( + err.to_string() + .contains("DangerFullAccess and ExternalSandbox are not supported") + ); } #[test] diff --git a/codex-rs/windows-sandbox-rs/src/process.rs b/codex-rs/windows-sandbox-rs/src/process.rs index 1571bb8ccf..0899d34c2f 100644 --- a/codex-rs/windows-sandbox-rs/src/process.rs +++ b/codex-rs/windows-sandbox-rs/src/process.rs @@ -4,26 +4,26 @@ use crate::proc_thread_attr::ProcThreadAttributeList; use crate::winutil::argv_to_command_line; use crate::winutil::format_last_error; use crate::winutil::to_wide; -use anyhow::anyhow; use anyhow::Result; +use anyhow::anyhow; use std::collections::HashMap; use std::ffi::c_void; use std::path::Path; use std::ptr; -use windows_sys::Win32::Foundation::GetLastError; use windows_sys::Win32::Foundation::CloseHandle; -use windows_sys::Win32::Foundation::SetHandleInformation; +use windows_sys::Win32::Foundation::GetLastError; use windows_sys::Win32::Foundation::HANDLE; use windows_sys::Win32::Foundation::HANDLE_FLAG_INHERIT; use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; +use windows_sys::Win32::Foundation::SetHandleInformation; use windows_sys::Win32::Storage::FileSystem::ReadFile; use windows_sys::Win32::System::Console::GetStdHandle; use windows_sys::Win32::System::Console::STD_ERROR_HANDLE; use windows_sys::Win32::System::Console::STD_INPUT_HANDLE; use windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE; use windows_sys::Win32::System::Pipes::CreatePipe; -use windows_sys::Win32::System::Threading::CreateProcessAsUserW; use windows_sys::Win32::System::Threading::CREATE_UNICODE_ENVIRONMENT; +use windows_sys::Win32::System::Threading::CreateProcessAsUserW; use windows_sys::Win32::System::Threading::EXTENDED_STARTUPINFO_PRESENT; use windows_sys::Win32::System::Threading::PROCESS_INFORMATION; use windows_sys::Win32::System::Threading::STARTF_USESTDHANDLES; diff --git a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs b/codex-rs/windows-sandbox-rs/src/setup.rs similarity index 100% rename from codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs rename to codex-rs/windows-sandbox-rs/src/setup.rs diff --git a/codex-rs/windows-sandbox-rs/src/token.rs b/codex-rs/windows-sandbox-rs/src/token.rs index 71a4b1dd7c..aabf77469f 100644 --- a/codex-rs/windows-sandbox-rs/src/token.rs +++ b/codex-rs/windows-sandbox-rs/src/token.rs @@ -1,18 +1,18 @@ use crate::winutil::to_wide; -use anyhow::anyhow; use anyhow::Result; +use anyhow::anyhow; use std::ffi::c_void; use windows_sys::Win32::Foundation::CloseHandle; -use windows_sys::Win32::Foundation::GetLastError; -use windows_sys::Win32::Foundation::LocalFree; use windows_sys::Win32::Foundation::ERROR_SUCCESS; +use windows_sys::Win32::Foundation::GetLastError; use windows_sys::Win32::Foundation::HANDLE; use windows_sys::Win32::Foundation::HLOCAL; use windows_sys::Win32::Foundation::LUID; +use windows_sys::Win32::Foundation::LocalFree; use windows_sys::Win32::Security::AdjustTokenPrivileges; -use windows_sys::Win32::Security::Authorization::SetEntriesInAclW; use windows_sys::Win32::Security::Authorization::EXPLICIT_ACCESS_W; use windows_sys::Win32::Security::Authorization::GRANT_ACCESS; +use windows_sys::Win32::Security::Authorization::SetEntriesInAclW; use windows_sys::Win32::Security::Authorization::TRUSTEE_IS_SID; use windows_sys::Win32::Security::Authorization::TRUSTEE_IS_UNKNOWN; use windows_sys::Win32::Security::Authorization::TRUSTEE_W; @@ -24,9 +24,6 @@ use windows_sys::Win32::Security::GetTokenInformation; use windows_sys::Win32::Security::LookupPrivilegeValueW; use windows_sys::Win32::Security::SetTokenInformation; -use windows_sys::Win32::Security::TokenDefaultDacl; -use windows_sys::Win32::Security::TokenGroups; -use windows_sys::Win32::Security::TokenUser; use windows_sys::Win32::Security::ACL; use windows_sys::Win32::Security::SID_AND_ATTRIBUTES; use windows_sys::Win32::Security::TOKEN_ADJUST_DEFAULT; @@ -37,6 +34,9 @@ use windows_sys::Win32::Security::TOKEN_DUPLICATE; use windows_sys::Win32::Security::TOKEN_PRIVILEGES; use windows_sys::Win32::Security::TOKEN_QUERY; use windows_sys::Win32::Security::TOKEN_USER; +use windows_sys::Win32::Security::TokenDefaultDacl; +use windows_sys::Win32::Security::TokenGroups; +use windows_sys::Win32::Security::TokenUser; use windows_sys::Win32::System::Threading::GetCurrentProcess; const DISABLE_MAX_PRIVILEGE: u32 = 0x01; @@ -136,11 +136,7 @@ pub unsafe fn convert_string_sid_to_sid(s: &str) -> Option<*mut c_void> { } let mut psid: *mut c_void = std::ptr::null_mut(); let ok = unsafe { ConvertStringSidToSidW(to_wide(s).as_ptr(), &mut psid) }; - if ok != 0 { - Some(psid) - } else { - None - } + if ok != 0 { Some(psid) } else { None } } /// # Safety @@ -276,7 +272,10 @@ unsafe fn get_user_sid_bytes(h_token: HANDLE) -> Result> { let token_user: TOKEN_USER = std::ptr::read_unaligned(user_buf.as_ptr() as *const TOKEN_USER); let sid_len = GetLengthSid(token_user.User.Sid); if sid_len == 0 { - return Err(anyhow!("GetLengthSid(TokenUser) failed: {}", GetLastError())); + return Err(anyhow!( + "GetLengthSid(TokenUser) failed: {}", + GetLastError() + )); } let mut user_sid_bytes = vec![0u8; sid_len as usize]; if CopySid( diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/session.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/mod.rs similarity index 100% rename from codex-rs/windows-sandbox-rs/src/unified_exec/session.rs rename to codex-rs/windows-sandbox-rs/src/unified_exec/mod.rs diff --git a/codex-rs/windows-sandbox-rs/src/wfp.rs b/codex-rs/windows-sandbox-rs/src/wfp.rs index 36d72e6377..9291b6eb99 100644 --- a/codex-rs/windows-sandbox-rs/src/wfp.rs +++ b/codex-rs/windows-sandbox-rs/src/wfp.rs @@ -1,6 +1,3 @@ -#![cfg(target_os = "windows")] - -#[path = "wfp_filter_specs.rs"] mod filter_specs; use crate::to_wide; @@ -9,23 +6,22 @@ use std::ffi::OsStr; use std::mem::zeroed; use std::ptr::null; use std::ptr::null_mut; -use windows_sys::core::GUID; use windows_sys::Win32::Foundation::FWP_E_ALREADY_EXISTS; use windows_sys::Win32::Foundation::FWP_E_FILTER_NOT_FOUND; use windows_sys::Win32::Foundation::FWP_E_NOT_FOUND; use windows_sys::Win32::Foundation::HANDLE; use windows_sys::Win32::Foundation::HLOCAL; use windows_sys::Win32::Foundation::LocalFree; -use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWP_ACTRL_MATCH_FILTER; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWP_ACTION_BLOCK; +use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWP_ACTRL_MATCH_FILTER; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWP_BYTE_BLOB; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWP_CONDITION_VALUE0; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWP_CONDITION_VALUE0_0; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWP_EMPTY; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWP_MATCH_EQUAL; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWP_SECURITY_DESCRIPTOR_TYPE; -use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWP_UINT16; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWP_UINT8; +use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWP_UINT16; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWP_VALUE0; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_ACTION0; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_ACTION0_0; @@ -33,15 +29,15 @@ use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_CONDIT use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_CONDITION_IP_PROTOCOL; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_CONDITION_IP_REMOTE_PORT; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_DISPLAY_DATA0; -use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_FILTER0; -use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_FILTER0_0; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_FILTER_CONDITION0; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_FILTER_FLAG_PERSISTENT; -use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_PROVIDER0; +use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_FILTER0; +use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_FILTER0_0; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_PROVIDER_FLAG_PERSISTENT; +use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_PROVIDER0; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_SESSION0; -use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_SUBLAYER0; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_SUBLAYER_FLAG_PERSISTENT; +use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_SUBLAYER0; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FwpmEngineClose0; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FwpmEngineOpen0; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FwpmFilterAdd0; @@ -58,6 +54,7 @@ use windows_sys::Win32::Security::Authorization::GRANT_ACCESS; use windows_sys::Win32::Security::PSECURITY_DESCRIPTOR; use windows_sys::Win32::System::Rpc::RPC_C_AUTHN_DEFAULT; use windows_sys::Win32::System::Threading::INFINITE; +use windows_sys::core::GUID; use filter_specs::ConditionSpec; use filter_specs::FILTER_SPECS; @@ -267,7 +264,11 @@ fn ensure_sublayer(engine: HANDLE) -> Result<()> { } /// Adds one blocking WFP filter from the static filter spec list. -fn add_filter(engine: HANDLE, spec: &FilterSpec, user_condition: &UserMatchCondition) -> Result<()> { +fn add_filter( + engine: HANDLE, + spec: &FilterSpec, + user_condition: &UserMatchCondition, +) -> Result<()> { let filter_name = to_wide(OsStr::new(spec.name)); let filter_description = to_wide(OsStr::new(spec.description)); let mut filter_conditions = build_conditions(spec.conditions, user_condition); @@ -288,7 +289,9 @@ fn add_filter(engine: HANDLE, spec: &FilterSpec, user_condition: &UserMatchCondi filterCondition: filter_conditions.as_mut_ptr(), action: FWPM_ACTION0 { r#type: FWP_ACTION_BLOCK, - Anonymous: FWPM_ACTION0_0 { filterType: zero_guid() }, + Anonymous: FWPM_ACTION0_0 { + filterType: zero_guid(), + }, }, Anonymous: FWPM_FILTER0_0 { rawContext: 0 }, reserved: null_mut(), @@ -396,14 +399,24 @@ mod tests { fn filter_keys_are_unique() { let keys = FILTER_SPECS .iter() - .map(|spec| (spec.key.data1, spec.key.data2, spec.key.data3, spec.key.data4)) + .map(|spec| { + ( + spec.key.data1, + spec.key.data2, + spec.key.data3, + spec.key.data4, + ) + }) .collect::>(); assert_eq!(keys.len(), FILTER_SPECS.len()); } #[test] fn filter_names_are_unique() { - let names = FILTER_SPECS.iter().map(|spec| spec.name).collect::>(); + let names = FILTER_SPECS + .iter() + .map(|spec| spec.name) + .collect::>(); assert_eq!(names.len(), FILTER_SPECS.len()); } } diff --git a/codex-rs/windows-sandbox-rs/src/wfp_filter_specs.rs b/codex-rs/windows-sandbox-rs/src/wfp/filter_specs.rs similarity index 90% rename from codex-rs/windows-sandbox-rs/src/wfp_filter_specs.rs rename to codex-rs/windows-sandbox-rs/src/wfp/filter_specs.rs index 2953200253..d86df4ac1e 100644 --- a/codex-rs/windows-sandbox-rs/src/wfp_filter_specs.rs +++ b/codex-rs/windows-sandbox-rs/src/wfp/filter_specs.rs @@ -1,12 +1,10 @@ -#![cfg(target_os = "windows")] - -use windows_sys::core::GUID; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_LAYER_ALE_AUTH_CONNECT_V4; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_LAYER_ALE_AUTH_CONNECT_V6; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_LAYER_ALE_RESOURCE_ASSIGNMENT_V4; use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::FWPM_LAYER_ALE_RESOURCE_ASSIGNMENT_V6; use windows_sys::Win32::Networking::WinSock::IPPROTO_ICMP; use windows_sys::Win32::Networking::WinSock::IPPROTO_ICMPV6; +use windows_sys::core::GUID; #[derive(Clone, Copy)] pub(super) enum ConditionSpec { @@ -30,28 +28,40 @@ pub(super) const FILTER_SPECS: &[FilterSpec] = &[ name: "codex_wfp_icmp_connect_v4", description: "Block sandbox-account ICMP connect v4", layer_key: FWPM_LAYER_ALE_AUTH_CONNECT_V4, - conditions: &[ConditionSpec::User, ConditionSpec::Protocol(IPPROTO_ICMP as u8)], + conditions: &[ + ConditionSpec::User, + ConditionSpec::Protocol(IPPROTO_ICMP as u8), + ], }, FilterSpec { key: GUID::from_u128(0x87498484_45ab_4510_845e_ece8b791b3bc), name: "codex_wfp_icmp_connect_v6", description: "Block sandbox-account ICMP connect v6", layer_key: FWPM_LAYER_ALE_AUTH_CONNECT_V6, - conditions: &[ConditionSpec::User, ConditionSpec::Protocol(IPPROTO_ICMPV6 as u8)], + conditions: &[ + ConditionSpec::User, + ConditionSpec::Protocol(IPPROTO_ICMPV6 as u8), + ], }, FilterSpec { key: GUID::from_u128(0xaf4751de_f874_4a7b_a34d_f0d0f22d1d9b), name: "codex_wfp_icmp_assign_v4", description: "Block sandbox-account ICMP resource assignment v4", layer_key: FWPM_LAYER_ALE_RESOURCE_ASSIGNMENT_V4, - conditions: &[ConditionSpec::User, ConditionSpec::Protocol(IPPROTO_ICMP as u8)], + conditions: &[ + ConditionSpec::User, + ConditionSpec::Protocol(IPPROTO_ICMP as u8), + ], }, FilterSpec { key: GUID::from_u128(0xea10db66_a928_4b2e_a82e_a376a54f93ba), name: "codex_wfp_icmp_assign_v6", description: "Block sandbox-account ICMP resource assignment v6", layer_key: FWPM_LAYER_ALE_RESOURCE_ASSIGNMENT_V6, - conditions: &[ConditionSpec::User, ConditionSpec::Protocol(IPPROTO_ICMPV6 as u8)], + conditions: &[ + ConditionSpec::User, + ConditionSpec::Protocol(IPPROTO_ICMPV6 as u8), + ], }, // NAME_RESOLUTION_CACHE filters are intentionally omitted because ordinary // static filter shapes returned FWP_E_OUT_OF_BOUNDS during validation. diff --git a/codex-rs/windows-sandbox-rs/src/wfp_setup.rs b/codex-rs/windows-sandbox-rs/src/wfp_setup.rs index 89d29118c2..351d2edd2f 100644 --- a/codex-rs/windows-sandbox-rs/src/wfp_setup.rs +++ b/codex-rs/windows-sandbox-rs/src/wfp_setup.rs @@ -171,11 +171,5 @@ pub fn install_wfp_filters( } }; - emit_wfp_setup_metric_safely( - codex_home, - otel, - offline_username, - &metric, - &mut log, - ); + emit_wfp_setup_metric_safely(codex_home, otel, offline_username, &metric, &mut log); } diff --git a/codex-rs/windows-sandbox-rs/src/winutil.rs b/codex-rs/windows-sandbox-rs/src/winutil.rs index f014e06072..45378b4bb5 100644 --- a/codex-rs/windows-sandbox-rs/src/winutil.rs +++ b/codex-rs/windows-sandbox-rs/src/winutil.rs @@ -3,18 +3,18 @@ use std::ffi::OsStr; use std::os::windows::ffi::OsStrExt; use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; use windows_sys::Win32::Foundation::GetLastError; -use windows_sys::Win32::Foundation::LocalFree; use windows_sys::Win32::Foundation::HLOCAL; +use windows_sys::Win32::Foundation::LocalFree; +use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW; use windows_sys::Win32::Security::Authorization::ConvertStringSidToSidW; use windows_sys::Win32::Security::CopySid; use windows_sys::Win32::Security::GetLengthSid; use windows_sys::Win32::Security::LookupAccountNameW; use windows_sys::Win32::Security::SID_NAME_USE; -use windows_sys::Win32::System::Diagnostics::Debug::FormatMessageW; use windows_sys::Win32::System::Diagnostics::Debug::FORMAT_MESSAGE_ALLOCATE_BUFFER; use windows_sys::Win32::System::Diagnostics::Debug::FORMAT_MESSAGE_FROM_SYSTEM; use windows_sys::Win32::System::Diagnostics::Debug::FORMAT_MESSAGE_IGNORE_INSERTS; -use windows_sys::Win32::Security::Authorization::ConvertSidToStringSidW; +use windows_sys::Win32::System::Diagnostics::Debug::FormatMessageW; pub fn to_wide>(s: S) -> Vec { let mut v: Vec = s.as_ref().encode_wide().collect(); @@ -107,7 +107,10 @@ pub fn string_from_sid_bytes(sid: &[u8]) -> Result { let mut str_ptr: *mut u16 = std::ptr::null_mut(); let ok = ConvertSidToStringSidW(sid.as_ptr() as *mut std::ffi::c_void, &mut str_ptr); if ok == 0 || str_ptr.is_null() { - return Err(format!("ConvertSidToStringSidW failed: {}", std::io::Error::last_os_error())); + return Err(format!( + "ConvertSidToStringSidW failed: {}", + std::io::Error::last_os_error() + )); } let mut len = 0; while *str_ptr.add(len) != 0 { @@ -158,7 +161,9 @@ pub fn resolve_sid(name: &str) -> Result> { domain.resize(domain_len as usize, 0); continue; } - return Err(anyhow::anyhow!("LookupAccountNameW failed for {name}: {err}")); + return Err(anyhow::anyhow!( + "LookupAccountNameW failed for {name}: {err}" + )); } } From 2f3a2d7a86cdfe082ed5c6efba0021162a75ffcf Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Fri, 8 May 2026 14:14:11 -0700 Subject: [PATCH 14/27] Using cached connector directory for discoverable tools list (#21497) ## Summary Startup tool construction currently depends on connector directory metadata for `tool_suggest` discoverables. On a cold directory cache, that can put slow connector-directory requests on the blocking path even though the tools array only needs directory data for install suggestions, not for the live connector MCP tools themselves. This PR keeps the discoverables path off that cold network fetch: - read connector directory metadata from cache only when building discoverable tools - persist connector directory metadata to `~/.codex/cache/codex_app_directory/.json` and use it to hydrate the in-memory cache on later runs before the normal refresh path updates it - use connector-directory-specific cache naming to distinguish this metadata cache from the separate Codex Apps tools-spec cache This reduces first-turn startup work without changing how live connector MCP tools are sourced. Longer term, directory-backed install suggestions should move to a search-based flow so they no longer need to be inlined into the tools prompt at all. ## Testing - `cargo test -p codex-connectors` - `cargo test -p codex-chatgpt` - `cargo test -p codex-core request_plugin_install_is_available_without_search_tool_after_discovery_attempts` - `cargo test -p codex-core tool_suggest_uses_connector_id_fallback_when_directory_cache_is_empty` --- codex-rs/Cargo.lock | 4 + codex-rs/chatgpt/src/connectors.rs | 29 ++- codex-rs/connectors/Cargo.toml | 4 + codex-rs/connectors/src/directory_cache.rs | 112 +++++++++ codex-rs/connectors/src/lib.rs | 267 ++++++++++++++++++--- codex-rs/core/src/connectors.rs | 85 ++----- codex-rs/core/src/connectors_tests.rs | 37 +++ 7 files changed, 425 insertions(+), 113 deletions(-) create mode 100644 codex-rs/connectors/src/directory_cache.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 40ca5c809d..5914f0cbc8 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2441,7 +2441,11 @@ dependencies = [ "codex-app-server-protocol", "pretty_assertions", "serde", + "serde_json", + "sha1", + "tempfile", "tokio", + "tracing", "urlencoding", ] 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/connectors/Cargo.toml b/codex-rs/connectors/Cargo.toml index c0094102c3..1ebdae32dc 100644 --- a/codex-rs/connectors/Cargo.toml +++ b/codex-rs/connectors/Cargo.toml @@ -11,10 +11,14 @@ 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] 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/src/connectors.rs b/codex-rs/core/src/connectors.rs index 718b2d402a..7a66e4ffa6 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -6,21 +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::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; @@ -35,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; @@ -48,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 { @@ -119,9 +114,11 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( auth: Option<&CodexAuth>, accessible_connectors: &[AppInfo], ) -> anyhow::Result> { - let directory_connectors = - list_directory_connectors_for_tool_suggest_with_auth(config, auth).await?; let connector_ids = tool_suggest_connector_ids(config).await; + let directory_connectors = codex_connectors::merge::merge_plugin_connectors( + cached_directory_connectors_for_tool_suggest_with_auth(config, auth).await, + connector_ids.iter().cloned(), + ); let discoverable_connectors = codex_connectors::filter::filter_tool_suggest_discoverable_connectors( directory_connectors, @@ -435,12 +432,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; @@ -453,67 +450,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 { diff --git a/codex-rs/core/src/connectors_tests.rs b/codex-rs/core/src/connectors_tests.rs index 014ab1cad8..6ded1610af 100644 --- a/codex-rs/core/src/connectors_tests.rs +++ b/codex-rs/core/src/connectors_tests.rs @@ -19,6 +19,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; @@ -1120,6 +1121,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( From 24111790f060891f0709aff805b4fd13cfbde1bf Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 8 May 2026 15:14:33 -0700 Subject: [PATCH 15/27] ci: check out PR head commits in workflows (#21835) ## Why PR CI should test the exact commit that was pushed to the PR branch. By default, GitHub's `pull_request` event checks out a synthetic merge commit from `refs/pull//merge`, so the tested tree can include an implicit merge with the current base branch instead of matching the pushed head SHA. Using the PR head SHA makes each check result correspond to a concrete commit the author submitted. This also behaves better for stacked PR workflows, including Sapling stacks and other Git stack tooling: a middle PR's head commit already contains the lower stack changes in its tree, without pulling in commits above it or GitHub's temporary merge ref. ## What Changed - Set every `actions/checkout` in `pull_request` workflows under `.github/workflows` to use `github.event.pull_request.head.sha` on PR events and `github.sha` otherwise. - Updated `blob-size-policy` to compare `github.event.pull_request.base.sha` and `github.event.pull_request.head.sha`, since it no longer checks out GitHub's merge commit where `HEAD^1`/`HEAD^2` represented the PR range. ## Verification - Parsed the edited workflow YAML files with Ruby. - Checked that every checkout block in the `pull_request` workflows has the PR-head `ref`. --- .github/workflows/bazel.yml | 4 ++++ .github/workflows/blob-size-policy.yml | 5 +++-- .github/workflows/cargo-deny.yml | 1 + .github/workflows/ci.yml | 1 + .github/workflows/codespell.yml | 1 + .github/workflows/rust-ci.yml | 5 +++++ .github/workflows/sdk.yml | 1 + .github/workflows/v8-canary.yml | 2 ++ 8 files changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 627993c811..cc3968d306 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -58,6 +58,7 @@ 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 @@ -152,6 +153,7 @@ 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 @@ -237,6 +239,7 @@ 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 @@ -326,6 +329,7 @@ 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 diff --git a/.github/workflows/blob-size-policy.yml b/.github/workflows/blob-size-policy.yml index e7817a9f65..779198ee02 100644 --- a/.github/workflows/blob-size-policy.yml +++ b/.github/workflows/blob-size-policy.yml @@ -10,6 +10,7 @@ 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 @@ -18,8 +19,8 @@ jobs: 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 94967e7e79..f20d09e112 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -16,6 +16,7 @@ jobs: - 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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3754155912..a1c60acc26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,7 @@ jobs: - 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 diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index d4a8469a53..aaa15cf40d 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -20,6 +20,7 @@ jobs: - 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 diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 52322913e5..75c5c33601 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -16,6 +16,7 @@ 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) @@ -64,6 +65,7 @@ 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 - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 with: @@ -82,6 +84,7 @@ 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 - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 @@ -101,6 +104,7 @@ 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 - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 - name: Install nightly argument-comment-lint toolchain @@ -179,6 +183,7 @@ jobs: - 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' }} diff --git a/.github/workflows/sdk.yml b/.github/workflows/sdk.yml index cc26a4785a..0f9065941b 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -15,6 +15,7 @@ jobs: - 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 diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 50ec353c2e..6e71367d1f 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -42,6 +42,7 @@ 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 @@ -78,6 +79,7 @@ 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 From dac108f2f1a308af7f80828cbd25bd86b4f6ee4f Mon Sep 17 00:00:00 2001 From: starr-openai Date: Fri, 8 May 2026 15:30:00 -0700 Subject: [PATCH 16/27] Make environment provider snapshots path-free (#21794) ## Summary - make EnvironmentProvider::snapshot path-free and keep providers focused on provider-owned remote environments - let provider snapshots request local inclusion via include_local, with environments.toml including local and CODEX_EXEC_SERVER_URL excluding local - move reserved local environment construction into EnvironmentManager using ExecServerRuntimePaths Follow-up to https://github.com/openai/codex/pull/20667 ## Testing - just fmt - git diff --check - devbox: bazel build --bes_backend= --bes_results_url= //codex-rs/exec-server:exec-server - devbox: bazel test --bes_backend= --bes_results_url= //codex-rs/exec-server:exec-server-unit-tests Co-authored-by: Codex --- .../app-server/tests/suite/v2/turn_start.rs | 41 ++---- codex-rs/core/src/environment_selection.rs | 31 +++- codex-rs/core/src/thread_manager_tests.rs | 14 +- codex-rs/exec-server/src/environment.rs | 137 +++++++++++------- .../exec-server/src/environment_provider.rs | 98 ++++--------- codex-rs/exec-server/src/environment_toml.rs | 72 +++------ 6 files changed, 187 insertions(+), 206 deletions(-) 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 586fcef87f..524b795b81 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1991,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)?; @@ -2000,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 [ @@ -2024,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"]), @@ -2044,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/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index 640d813243..89808c27ee 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -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; @@ -109,15 +110,41 @@ mod tests { ) .await; + assert_eq!( + default_thread_environment_selections(&manager, &cwd), + vec![TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd, + }] + ); + } + + #[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: REMOTE_ENVIRONMENT_ID.to_string(), + environment_id: LOCAL_ENVIRONMENT_ID.to_string(), cwd: cwd.clone(), }, TurnEnvironmentSelection { - environment_id: "local".to_string(), + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), cwd, }, ] diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 3bc5c77841..21fa03ad7f 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -293,7 +293,7 @@ async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() { } #[tokio::test] -async fn start_thread_accepts_explicit_environment_when_default_environment_is_disabled() { +async fn start_thread_rejects_explicit_local_environment_when_default_provider_is_disabled() { let temp_dir = tempdir().expect("tempdir"); let mut config = test_config().await; config.codex_home = temp_dir.path().join("codex-home").abs(); @@ -319,7 +319,7 @@ async fn start_thread_accepts_explicit_environment_when_default_environment_is_d environment_manager, ); - let thread = manager + let result = manager .start_thread_with_options(StartThreadOptions { config: config.clone(), initial_history: InitialHistory::New, @@ -334,10 +334,14 @@ async fn start_thread_accepts_explicit_environment_when_default_environment_is_d cwd: config.cwd.clone(), }], }) - .await - .expect("explicit sticky environment should resolve by id"); + .await; + let err = match result { + Ok(_) => panic!("explicit local environment should not resolve when provider is disabled"), + Err(err) => err, + }; - assert_eq!(manager.list_thread_ids().await, vec![thread.thread_id]); + assert_eq!(err.to_string(), "unknown turn environment id `local`"); + assert!(manager.list_thread_ids().await.is_empty()); } #[tokio::test] diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index be83393bb5..b8983cd84c 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -142,10 +142,7 @@ impl EnvironmentManager { where P: EnvironmentProvider + ?Sized, { - Self::from_provider_snapshot( - provider.snapshot(&local_runtime_paths).await?, - local_runtime_paths, - ) + Self::from_provider_snapshot(provider.snapshot().await?, local_runtime_paths) } fn from_provider_snapshot( @@ -155,14 +152,28 @@ impl EnvironmentManager { let EnvironmentProviderSnapshot { environments, default, + include_local, } = snapshot; - let mut environment_map = HashMap::with_capacity(environments.len()); + let mut environment_map = + HashMap::with_capacity(environments.len() + usize::from(include_local)); + let local_environment = Arc::new(Environment::local(local_runtime_paths)); + if include_local { + environment_map.insert( + LOCAL_ENVIRONMENT_ID.to_string(), + Arc::clone(&local_environment), + ); + } for (id, environment) in environments { if id.is_empty() { return Err(ExecServerError::Protocol( "environment id cannot be empty".to_string(), )); } + if id == LOCAL_ENVIRONMENT_ID { + return Err(ExecServerError::Protocol(format!( + "environment id `{LOCAL_ENVIRONMENT_ID}` is reserved for EnvironmentManager" + ))); + } if environment_map .insert(id.clone(), Arc::new(environment)) .is_some() @@ -183,8 +194,6 @@ impl EnvironmentManager { Some(environment_id) } }; - let local_environment = Arc::new(Environment::local(local_runtime_paths)); - Ok(Self { default_environment, environments: environment_map, @@ -399,10 +408,7 @@ mod tests { #[async_trait::async_trait] impl EnvironmentProvider for TestEnvironmentProvider { - async fn snapshot( - &self, - _local_runtime_paths: &ExecServerRuntimePaths, - ) -> Result { + async fn snapshot(&self) -> Result { Ok(self.snapshot.clone()) } } @@ -431,14 +437,15 @@ mod tests { let environment = manager.default_environment().expect("default environment"); assert_eq!(manager.default_environment_id(), Some(LOCAL_ENVIRONMENT_ID)); - assert!(!environment.is_remote()); - assert!( - !manager + assert!(Arc::ptr_eq( + &environment, + &manager .get_environment(LOCAL_ENVIRONMENT_ID) .expect("local environment") - .is_remote() - ); + )); + assert!(Arc::ptr_eq(&environment, &manager.local_environment())); assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); + assert!(!environment.is_remote()); } #[tokio::test] @@ -473,12 +480,7 @@ mod tests { .get_environment(REMOTE_ENVIRONMENT_ID) .expect("remote environment") )); - assert!( - !manager - .get_environment(LOCAL_ENVIRONMENT_ID) - .expect("local environment") - .is_remote() - ); + assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none()); assert!(!manager.local_environment().is_remote()); } @@ -506,6 +508,7 @@ mod tests { .expect("remote environment"), )], default: EnvironmentDefault::EnvironmentId(REMOTE_ENVIRONMENT_ID.to_string()), + include_local: false, }, }; let manager = EnvironmentManager::from_provider(&provider, test_runtime_paths()) @@ -532,6 +535,7 @@ mod tests { snapshot: EnvironmentProviderSnapshot { environments: vec![("".to_string(), Environment::default_for_tests())], default: EnvironmentDefault::Disabled, + include_local: false, }, }; let err = EnvironmentManager::from_provider(&provider, test_runtime_paths()) @@ -544,22 +548,39 @@ mod tests { ); } + #[tokio::test] + async fn environment_manager_rejects_provider_supplied_local_environment() { + let provider = TestEnvironmentProvider { + snapshot: EnvironmentProviderSnapshot { + environments: vec![( + LOCAL_ENVIRONMENT_ID.to_string(), + Environment::default_for_tests(), + )], + default: EnvironmentDefault::Disabled, + include_local: false, + }, + }; + let err = EnvironmentManager::from_provider(&provider, test_runtime_paths()) + .await + .expect_err("local id should fail"); + + assert_eq!( + err.to_string(), + "exec-server protocol error: environment id `local` is reserved for EnvironmentManager" + ); + } + #[tokio::test] async fn environment_manager_uses_explicit_provider_default() { let provider = TestEnvironmentProvider { snapshot: EnvironmentProviderSnapshot { - environments: vec![ - ( - LOCAL_ENVIRONMENT_ID.to_string(), - Environment::default_for_tests(), - ), - ( - "devbox".to_string(), - Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) - .expect("remote environment"), - ), - ], + environments: vec![( + "devbox".to_string(), + Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) + .expect("remote environment"), + )], default: EnvironmentDefault::EnvironmentId("devbox".to_string()), + include_local: true, }, }; let manager = EnvironmentManager::from_provider(&provider, test_runtime_paths()) @@ -579,10 +600,12 @@ mod tests { let provider = TestEnvironmentProvider { snapshot: EnvironmentProviderSnapshot { environments: vec![( - LOCAL_ENVIRONMENT_ID.to_string(), - Environment::default_for_tests(), + "devbox".to_string(), + Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) + .expect("remote environment"), )], default: EnvironmentDefault::Disabled, + include_local: true, }, }; let manager = EnvironmentManager::from_provider(&provider, test_runtime_paths()) @@ -591,7 +614,12 @@ mod tests { assert_eq!(manager.default_environment_id(), None); assert!(manager.default_environment().is_none()); - assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_some()); + assert!(Arc::ptr_eq( + &manager + .get_environment(LOCAL_ENVIRONMENT_ID) + .expect("local environment"), + &manager.local_environment() + )); } #[tokio::test] @@ -599,10 +627,12 @@ mod tests { let provider = TestEnvironmentProvider { snapshot: EnvironmentProviderSnapshot { environments: vec![( - LOCAL_ENVIRONMENT_ID.to_string(), - Environment::default_for_tests(), + "devbox".to_string(), + Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string())) + .expect("remote environment"), )], default: EnvironmentDefault::EnvironmentId("missing".to_string()), + include_local: true, }, }; let err = EnvironmentManager::from_provider(&provider, test_runtime_paths()) @@ -616,20 +646,23 @@ mod tests { } #[tokio::test] - async fn environment_manager_uses_provider_supplied_local_environment() { + async fn environment_manager_includes_local_for_default_provider_without_url() { let manager = EnvironmentManager::create_for_tests( /*exec_server_url*/ None, test_runtime_paths(), ) .await; + let environment = manager.default_environment().expect("default environment"); assert_eq!(manager.default_environment_id(), Some(LOCAL_ENVIRONMENT_ID)); - let provider_local = manager - .get_environment(LOCAL_ENVIRONMENT_ID) - .expect("provider local environment"); - assert!(!provider_local.is_remote()); - assert!(!manager.local_environment().is_remote()); - assert!(!Arc::ptr_eq(&provider_local, &manager.local_environment())); + assert!(Arc::ptr_eq( + &environment, + &manager + .get_environment(LOCAL_ENVIRONMENT_ID) + .expect("local environment") + )); + assert!(Arc::ptr_eq(&environment, &manager.local_environment())); + assert!(!environment.is_remote()); } #[tokio::test] @@ -641,7 +674,7 @@ mod tests { ) .await; - let environment = manager.default_environment().expect("default environment"); + let environment = manager.local_environment(); assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths)); let manager = EnvironmentManager::create_for_tests( @@ -652,7 +685,7 @@ mod tests { .clone(), ) .await; - let environment = manager.default_environment().expect("default environment"); + let environment = manager.local_environment(); assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths)); } @@ -665,20 +698,16 @@ mod tests { } #[tokio::test] - async fn environment_manager_keeps_default_provider_local_lookup_when_default_disabled() { + async fn environment_manager_omits_default_provider_local_lookup_when_default_disabled() { let manager = EnvironmentManager::create_for_tests(Some("none".to_string()), test_runtime_paths()) .await; assert!(manager.default_environment().is_none()); assert_eq!(manager.default_environment_id(), None); - assert!( - !manager - .get_environment(LOCAL_ENVIRONMENT_ID) - .expect("local environment") - .is_remote() - ); + assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none()); assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none()); + assert!(!manager.local_environment().is_remote()); } #[tokio::test] diff --git a/codex-rs/exec-server/src/environment_provider.rs b/codex-rs/exec-server/src/environment_provider.rs index bced67db55..7e132ee2b4 100644 --- a/codex-rs/exec-server/src/environment_provider.rs +++ b/codex-rs/exec-server/src/environment_provider.rs @@ -2,7 +2,6 @@ use async_trait::async_trait; use crate::Environment; use crate::ExecServerError; -use crate::ExecServerRuntimePaths; use crate::environment::CODEX_EXEC_SERVER_URL_ENV_VAR; use crate::environment::LOCAL_ENVIRONMENT_ID; use crate::environment::REMOTE_ENVIRONMENT_ID; @@ -11,21 +10,20 @@ use crate::environment::REMOTE_ENVIRONMENT_ID; /// /// Implementations own a startup snapshot containing both the available /// environment list in configured order and the default environment -/// selection. Providers that want the local environment to be addressable by -/// id should include it explicitly in the returned list. +/// selection. Providers should only return provider-owned remote environments; +/// `include_local` controls whether `EnvironmentManager` should add the local +/// environment to the snapshot. #[async_trait] pub trait EnvironmentProvider: Send + Sync { /// Returns the provider-owned environment startup snapshot. - async fn snapshot( - &self, - local_runtime_paths: &ExecServerRuntimePaths, - ) -> Result; + async fn snapshot(&self) -> Result; } #[derive(Clone, Debug)] pub struct EnvironmentProviderSnapshot { pub environments: Vec<(String, Environment)>, pub default: EnvironmentDefault, + pub include_local: bool, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -51,26 +49,21 @@ impl DefaultEnvironmentProvider { Self::new(std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok()) } - pub(crate) fn snapshot_inner( - &self, - local_runtime_paths: &ExecServerRuntimePaths, - ) -> EnvironmentProviderSnapshot { - let mut environments = vec![( - LOCAL_ENVIRONMENT_ID.to_string(), - Environment::local(local_runtime_paths.clone()), - )]; + pub(crate) fn snapshot_inner(&self) -> EnvironmentProviderSnapshot { + let mut environments = Vec::new(); let (exec_server_url, disabled) = normalize_exec_server_url(self.exec_server_url.clone()); if let Some(exec_server_url) = exec_server_url { environments.push(( REMOTE_ENVIRONMENT_ID.to_string(), - Environment::remote_inner(exec_server_url, Some(local_runtime_paths.clone())), + Environment::remote_inner(exec_server_url, /*local_runtime_paths*/ None), )); } let has_remote = environments .iter() .any(|(id, _environment)| id == REMOTE_ENVIRONMENT_ID); + let include_local = !disabled && !has_remote; let default = if disabled { EnvironmentDefault::Disabled } else if has_remote { @@ -82,17 +75,15 @@ impl DefaultEnvironmentProvider { EnvironmentProviderSnapshot { environments, default, + include_local, } } } #[async_trait] impl EnvironmentProvider for DefaultEnvironmentProvider { - async fn snapshot( - &self, - local_runtime_paths: &ExecServerRuntimePaths, - ) -> Result { - Ok(self.snapshot_inner(local_runtime_paths)) + async fn snapshot(&self) -> Result { + Ok(self.snapshot_inner()) } } @@ -111,35 +102,20 @@ mod tests { use pretty_assertions::assert_eq; use super::*; - use crate::ExecServerRuntimePaths; - - fn test_runtime_paths() -> ExecServerRuntimePaths { - ExecServerRuntimePaths::new( - std::env::current_exe().expect("current exe"), - /*codex_linux_sandbox_exe*/ None, - ) - .expect("runtime paths") - } #[tokio::test] - async fn default_provider_returns_local_environment_when_url_is_missing() { + async fn default_provider_requests_local_environment_when_url_is_missing() { let provider = DefaultEnvironmentProvider::new(/*exec_server_url*/ None); - let runtime_paths = test_runtime_paths(); - let snapshot = provider - .snapshot(&runtime_paths) - .await - .expect("environments"); + let snapshot = provider.snapshot().await.expect("environments"); let EnvironmentProviderSnapshot { environments, default, + include_local, } = snapshot; let environments: HashMap<_, _> = environments.into_iter().collect(); - assert!(!environments[LOCAL_ENVIRONMENT_ID].is_remote()); - assert_eq!( - environments[LOCAL_ENVIRONMENT_ID].local_runtime_paths(), - Some(&runtime_paths) - ); + assert!(include_local); + assert!(!environments.contains_key(LOCAL_ENVIRONMENT_ID)); assert!(!environments.contains_key(REMOTE_ENVIRONMENT_ID)); assert_eq!( default, @@ -148,20 +124,18 @@ mod tests { } #[tokio::test] - async fn default_provider_returns_local_environment_when_url_is_empty() { + async fn default_provider_requests_local_environment_when_url_is_empty() { let provider = DefaultEnvironmentProvider::new(Some(String::new())); - let runtime_paths = test_runtime_paths(); - let snapshot = provider - .snapshot(&runtime_paths) - .await - .expect("environments"); + let snapshot = provider.snapshot().await.expect("environments"); let EnvironmentProviderSnapshot { environments, default, + include_local, } = snapshot; let environments: HashMap<_, _> = environments.into_iter().collect(); - assert!(!environments[LOCAL_ENVIRONMENT_ID].is_remote()); + assert!(include_local); + assert!(!environments.contains_key(LOCAL_ENVIRONMENT_ID)); assert!(!environments.contains_key(REMOTE_ENVIRONMENT_ID)); assert_eq!( default, @@ -170,20 +144,18 @@ mod tests { } #[tokio::test] - async fn default_provider_returns_local_environment_for_none_value() { + async fn default_provider_omits_local_environment_for_none_value() { let provider = DefaultEnvironmentProvider::new(Some("none".to_string())); - let runtime_paths = test_runtime_paths(); - let snapshot = provider - .snapshot(&runtime_paths) - .await - .expect("environments"); + let snapshot = provider.snapshot().await.expect("environments"); let EnvironmentProviderSnapshot { environments, default, + include_local, } = snapshot; let environments: HashMap<_, _> = environments.into_iter().collect(); - assert!(!environments[LOCAL_ENVIRONMENT_ID].is_remote()); + assert!(!include_local); + assert!(!environments.contains_key(LOCAL_ENVIRONMENT_ID)); assert!(!environments.contains_key(REMOTE_ENVIRONMENT_ID)); assert_eq!(default, EnvironmentDefault::Disabled); } @@ -191,18 +163,16 @@ mod tests { #[tokio::test] async fn default_provider_adds_remote_environment_for_websocket_url() { let provider = DefaultEnvironmentProvider::new(Some("ws://127.0.0.1:8765".to_string())); - let runtime_paths = test_runtime_paths(); - let snapshot = provider - .snapshot(&runtime_paths) - .await - .expect("environments"); + let snapshot = provider.snapshot().await.expect("environments"); let EnvironmentProviderSnapshot { environments, default, + include_local, } = snapshot; let environments: HashMap<_, _> = environments.into_iter().collect(); - assert!(!environments[LOCAL_ENVIRONMENT_ID].is_remote()); + assert!(!include_local); + assert!(!environments.contains_key(LOCAL_ENVIRONMENT_ID)); let remote_environment = &environments[REMOTE_ENVIRONMENT_ID]; assert!(remote_environment.is_remote()); assert_eq!( @@ -218,11 +188,7 @@ mod tests { #[tokio::test] async fn default_provider_normalizes_exec_server_url() { let provider = DefaultEnvironmentProvider::new(Some(" ws://127.0.0.1:8765 ".to_string())); - let runtime_paths = test_runtime_paths(); - let snapshot = provider - .snapshot(&runtime_paths) - .await - .expect("environments"); + let snapshot = provider.snapshot().await.expect("environments"); let environments: HashMap<_, _> = snapshot.environments.into_iter().collect(); assert_eq!( diff --git a/codex-rs/exec-server/src/environment_toml.rs b/codex-rs/exec-server/src/environment_toml.rs index a1f328377a..2f5fd97790 100644 --- a/codex-rs/exec-server/src/environment_toml.rs +++ b/codex-rs/exec-server/src/environment_toml.rs @@ -11,7 +11,6 @@ use crate::DefaultEnvironmentProvider; use crate::Environment; use crate::EnvironmentProvider; use crate::ExecServerError; -use crate::ExecServerRuntimePaths; use crate::client_api::ExecServerTransportParams; use crate::client_api::StdioExecServerCommand; use crate::environment::LOCAL_ENVIRONMENT_ID; @@ -78,21 +77,14 @@ impl TomlEnvironmentProvider { #[async_trait] impl EnvironmentProvider for TomlEnvironmentProvider { - async fn snapshot( - &self, - local_runtime_paths: &ExecServerRuntimePaths, - ) -> Result { - let mut environments = Vec::with_capacity(self.environments.len() + 1); - environments.push(( - LOCAL_ENVIRONMENT_ID.to_string(), - Environment::local(local_runtime_paths.clone()), - )); + async fn snapshot(&self) -> Result { + let mut environments = Vec::with_capacity(self.environments.len()); for (id, transport_params) in &self.environments { environments.push(( id.clone(), Environment::remote_with_transport( transport_params.clone(), - Some(local_runtime_paths.clone()), + /*local_runtime_paths*/ None, ), )); } @@ -100,6 +92,7 @@ impl EnvironmentProvider for TomlEnvironmentProvider { Ok(EnvironmentProviderSnapshot { environments, default: self.default.clone(), + include_local: true, }) } } @@ -292,16 +285,8 @@ mod tests { use super::*; - fn test_runtime_paths() -> ExecServerRuntimePaths { - ExecServerRuntimePaths::new( - std::env::current_exe().expect("current exe"), - /*codex_linux_sandbox_exe*/ None, - ) - .expect("runtime paths") - } - #[tokio::test] - async fn toml_provider_adds_implicit_local_and_configured_environments() { + async fn toml_provider_includes_local_and_adds_configured_environments() { let provider = TomlEnvironmentProvider::new(EnvironmentsToml { default: Some("ssh-dev".to_string()), environments: vec![ @@ -326,27 +311,22 @@ mod tests { ], }) .expect("provider"); - let runtime_paths = test_runtime_paths(); - let snapshot = provider - .snapshot(&runtime_paths) - .await - .expect("environments"); + let snapshot = provider.snapshot().await.expect("environments"); let EnvironmentProviderSnapshot { environments, default, + include_local, } = snapshot; let environment_ids: Vec<_> = environments .iter() .map(|(id, _environment)| id.as_str()) .collect(); - assert_eq!( - environment_ids, - vec![LOCAL_ENVIRONMENT_ID, "devbox", "ssh-dev"] - ); + assert_eq!(environment_ids, vec!["devbox", "ssh-dev"]); let environments: HashMap<_, _> = environments.into_iter().collect(); - assert!(!environments[LOCAL_ENVIRONMENT_ID].is_remote()); + assert!(include_local); + assert!(!environments.contains_key(LOCAL_ENVIRONMENT_ID)); assert_eq!( environments["devbox"].exec_server_url(), Some("ws://127.0.0.1:8765") @@ -362,11 +342,9 @@ mod tests { #[tokio::test] async fn toml_provider_default_omitted_selects_local() { let provider = TomlEnvironmentProvider::new(EnvironmentsToml::default()).expect("provider"); - let snapshot = provider - .snapshot(&test_runtime_paths()) - .await - .expect("environments"); + let snapshot = provider.snapshot().await.expect("environments"); + assert!(snapshot.include_local); assert_eq!( snapshot.default, EnvironmentDefault::EnvironmentId(LOCAL_ENVIRONMENT_ID.to_string()) @@ -380,11 +358,9 @@ mod tests { environments: Vec::new(), }) .expect("provider"); - let snapshot = provider - .snapshot(&test_runtime_paths()) - .await - .expect("environments"); + let snapshot = provider.snapshot().await.expect("environments"); + assert!(snapshot.include_local); assert_eq!(snapshot.default, EnvironmentDefault::Disabled); } @@ -681,17 +657,15 @@ default = "none" let provider = environment_provider_from_codex_home(codex_home.path()).expect("environment provider"); - let snapshot = provider - .snapshot(&test_runtime_paths()) - .await - .expect("environments"); + let snapshot = provider.snapshot().await.expect("environments"); let environment_ids: Vec<_> = snapshot .environments .into_iter() .map(|(id, _environment)| id) .collect(); - assert!(environment_ids.contains(&LOCAL_ENVIRONMENT_ID.to_string())); + assert!(snapshot.include_local); + assert!(!environment_ids.contains(&LOCAL_ENVIRONMENT_ID.to_string())); assert_eq!(snapshot.default, EnvironmentDefault::Disabled); } @@ -702,16 +676,18 @@ default = "none" let provider = environment_provider_from_codex_home(codex_home.path()).expect("environment provider"); - let snapshot = provider - .snapshot(&test_runtime_paths()) - .await - .expect("environments"); + let snapshot = provider.snapshot().await.expect("environments"); let environment_ids: Vec<_> = snapshot .environments .into_iter() .map(|(id, _environment)| id) .collect(); - assert!(environment_ids.contains(&LOCAL_ENVIRONMENT_ID.to_string())); + assert!(snapshot.include_local); + assert!(!environment_ids.contains(&LOCAL_ENVIRONMENT_ID.to_string())); + assert_eq!( + snapshot.default, + EnvironmentDefault::EnvironmentId(LOCAL_ENVIRONMENT_ID.to_string()) + ); } } From 1b86906fa179f416a61159ba4b17857868c4c8cc Mon Sep 17 00:00:00 2001 From: Ruslan Nigmatullin Date: Fri, 8 May 2026 15:47:51 -0700 Subject: [PATCH 17/27] app-server: support daemon-safe restart handling (#21831) ## Why The app-server daemon work needs two app-server behaviors to be safe when lifecycle management is driven by a helper process: - a readiness probe must not become the process-wide client identity just because it connects first - a graceful reload signal needs to keep draining active turns even if it is delivered more than once ## What changed - Treat `codex_app_server_daemon` initialization as a probe-only client for process-global originator and user-agent suffix state. - Distinguish forceable shutdown signals from graceful-only ones, and treat Unix `SIGHUP` as graceful-only while leaving `SIGTERM` and Ctrl-C forceable. - Add regression coverage for daemon probe initialization and repeated `SIGHUP` delivery while a turn is still running. ## Testing - `cargo test -p codex-app-server` - The new daemon-probe and repeated-`SIGHUP` coverage passed. - The run still failed in the existing `suite::conversation_summary::get_conversation_summary_by_relative_rollout_path_resolves_from_codex_home` and `suite::conversation_summary::get_conversation_summary_by_thread_id_reads_rollout` tests because their initialize handshake timed out. - `cargo test -p codex-app-server --test all suite::conversation_summary::` - Reproduced the same two existing initialize-timeout failures in isolation. --- codex-rs/app-server/src/lib.rs | 42 ++++++++++++++----- .../initialize_processor.rs | 36 +++++++++------- .../v2/connection_handling_websocket_unix.rs | 32 ++++++++++++++ .../app-server/tests/suite/v2/initialize.rs | 27 ++++++++++++ 4 files changed, 111 insertions(+), 26 deletions(-) diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index af95800b24..dea4a20e5a 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -160,22 +160,33 @@ enum ShutdownAction { Finish, } -async fn shutdown_signal() -> IoResult<()> { +#[derive(Clone, Copy)] +enum ShutdownSignal { + Forceable, + #[cfg(unix)] + GracefulOnly, +} + +async fn shutdown_signal() -> IoResult { #[cfg(unix)] { use tokio::signal::unix::SignalKind; use tokio::signal::unix::signal; let mut term = signal(SignalKind::terminate())?; + let mut hangup = signal(SignalKind::hangup())?; tokio::select! { - ctrl_c_result = tokio::signal::ctrl_c() => ctrl_c_result, - _ = term.recv() => Ok(()), + ctrl_c_result = tokio::signal::ctrl_c() => ctrl_c_result.map(|_| ShutdownSignal::Forceable), + _ = term.recv() => Ok(ShutdownSignal::Forceable), + _ = hangup.recv() => Ok(ShutdownSignal::GracefulOnly), } } #[cfg(not(unix))] { - tokio::signal::ctrl_c().await + tokio::signal::ctrl_c() + .await + .map(|_| ShutdownSignal::Forceable) } } @@ -188,9 +199,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; } @@ -814,11 +832,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() { 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 c13ce4340f..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, @@ -90,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 { @@ -104,21 +107,22 @@ impl InitializeRequestProcessor { 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. + } } } } @@ -129,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/tests/suite/v2/connection_handling_websocket_unix.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs index 591b70af09..4b708d4ab3 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_unix.rs @@ -133,6 +133,34 @@ async fn websocket_transport_second_sigterm_forces_exit_while_turn_running() -> Ok(()) } +#[tokio::test] +async fn websocket_transport_repeated_sighup_keeps_waiting_for_running_turn() -> Result<()> { + let GracefulCtrlCFixture { + _codex_home, + _server, + mut process, + mut ws, + } = start_ctrl_c_restart_fixture(Duration::from_secs(3)).await?; + + send_sighup(&process)?; + assert_process_does_not_exit_within(&mut process, Duration::from_millis(300)).await?; + + send_sighup(&process)?; + assert_process_does_not_exit_within(&mut process, Duration::from_millis(300)).await?; + + let status = wait_for_process_exit_within( + &mut process, + Duration::from_secs(10), + "timed out waiting for graceful repeated SIGHUP restart shutdown", + ) + .await?; + assert!(status.success(), "expected graceful exit, got {status}"); + + expect_websocket_disconnect(&mut ws).await?; + + Ok(()) +} + struct GracefulCtrlCFixture { _codex_home: TempDir, _server: wiremock::MockServer, @@ -236,6 +264,10 @@ fn send_sigterm(process: &Child) -> Result<()> { send_signal(process, "-TERM") } +fn send_sighup(process: &Child) -> Result<()> { + send_signal(process, "-HUP") +} + fn send_signal(process: &Child, signal: &str) -> Result<()> { let pid = process .id() diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index dcfd4e5499..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(); From 80a408e201e3c01cf2943f88906d3fdbcd17065a Mon Sep 17 00:00:00 2001 From: lt-oai Date: Fri, 8 May 2026 15:56:13 -0700 Subject: [PATCH 18/27] Support openai library tool (#20293) Support chatgpt library tool --- codex-rs/utils/plugins/src/mcp_connector.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/codex-rs/utils/plugins/src/mcp_connector.rs b/codex-rs/utils/plugins/src/mcp_connector.rs index 40fb0d4bbf..0f02258c16 100644 --- a/codex-rs/utils/plugins/src/mcp_connector.rs +++ b/codex-rs/utils/plugins/src/mcp_connector.rs @@ -11,6 +11,7 @@ const DISALLOWED_CONNECTOR_IDS: &[&str] = &[ ]; const FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS: &[&str] = &["connector_0f9c9d4592e54d0a9a12b3f44a1e2010"]; +const ALLOWED_OPENAI_CONNECTOR_IDS: &[&str] = &["connector_openai_library"]; const DISALLOWED_CONNECTOR_PREFIX: &str = "connector_openai_"; pub fn is_connector_id_allowed(connector_id: &str) -> bool { @@ -24,6 +25,10 @@ fn is_connector_id_allowed_for_originator(connector_id: &str, originator_value: DISALLOWED_CONNECTOR_IDS }; + if ALLOWED_OPENAI_CONNECTOR_IDS.contains(&connector_id) { + return true; + } + !connector_id.starts_with(DISALLOWED_CONNECTOR_PREFIX) && !disallowed_connector_ids.contains(&connector_id) } From 8f4020846ef6ff17f652bec6136378ff438db1fc Mon Sep 17 00:00:00 2001 From: Michael Zeng Date: Fri, 8 May 2026 16:30:07 -0700 Subject: [PATCH 19/27] [codex] support executor registry remote environments (#21323) ## Summary Support registry-backed remote executors end to end so downstream services can resolve an executor id into an exec-server URL and make that environment available to Codex without relying on the legacy cloud environments flow. ## What changed - switch remote executor registration to the executor registry bootstrap contract - allow named remote environments to be inserted into `EnvironmentManager` at runtime - add the experimental app-server RPC `environment/add` so initialized experimental clients can register those remote environments for later `thread/start` and `turn/start` selection ## Validation Ran focused validation locally: - `cargo test -p codex-exec-server environment_manager_` - `cargo test -p codex-exec-server register_executor_posts_with_bearer_token_header` - `cargo test -p codex-app-server-protocol` --- codex-rs/Cargo.lock | 1 - .../src/protocol/common.rs | 59 ++++++++++- .../src/protocol/v2/environment.rs | 17 ++++ .../src/protocol/v2/mod.rs | 2 + codex-rs/app-server/README.md | 1 + codex-rs/app-server/src/message_processor.rs | 8 ++ codex-rs/app-server/src/request_processors.rs | 4 + .../environment_processor.rs | 24 +++++ codex-rs/exec-server/Cargo.toml | 1 - codex-rs/exec-server/src/environment.rs | 98 +++++++++++++++++-- codex-rs/exec-server/src/remote.rs | 92 ++++------------- 11 files changed, 222 insertions(+), 85 deletions(-) create mode 100644 codex-rs/app-server-protocol/src/protocol/v2/environment.rs create mode 100644 codex-rs/app-server/src/request_processors/environment_processor.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 5914f0cbc8..d7fce5bd7a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2737,7 +2737,6 @@ dependencies = [ "serde", "serde_json", "serial_test", - "sha2", "tempfile", "test-case", "thiserror 2.0.18", diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 4e0ca3d1cb..ae00b08b73 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -808,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, @@ -1796,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] @@ -2578,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"), }, @@ -2589,7 +2631,7 @@ mod tests { assert_eq!( json!({ "method": "fs/getMetadata", - "id": 9, + "id": 10, "params": { "path": absolute_path_string("tmp/example") } @@ -2850,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 { 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/mod.rs b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs index 32c24bff1d..b5fa9fdc65 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -6,6 +6,7 @@ mod attestation; mod collaboration_mode; mod command_exec; mod config; +mod environment; mod experimental_feature; mod feedback; mod fs; @@ -31,6 +32,7 @@ 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/README.md b/codex-rs/app-server/README.md index 9eed9ead72..9ff7d890ff 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -202,6 +202,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. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 062127d5a7..3853244958 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -18,6 +18,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; @@ -161,6 +162,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, @@ -446,6 +448,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() @@ -467,6 +471,7 @@ impl MessageProcessor { command_exec_processor, process_exec_processor, config_processor, + environment_processor, external_agent_config_processor, feedback_processor, fs_processor, @@ -878,6 +883,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) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 5bfe97d305..284d0fa1ff 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -49,6 +49,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; @@ -434,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; @@ -454,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; 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/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index c466a234c1..9fbdd91117 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -26,7 +26,6 @@ futures = { workspace = true } reqwest = { workspace = true, features = ["json", "rustls-tls", "stream"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -sha2 = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } tokio = { workspace = true, features = [ diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index b8983cd84c..049a2d0229 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::sync::Arc; +use std::sync::RwLock; use crate::ExecServerError; use crate::ExecServerRuntimePaths; @@ -40,7 +41,7 @@ pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL"; #[derive(Debug)] pub struct EnvironmentManager { default_environment: Option, - environments: HashMap>, + environments: RwLock>>, local_environment: Arc, } @@ -65,10 +66,10 @@ impl EnvironmentManager { pub fn default_for_tests() -> Self { Self { default_environment: Some(LOCAL_ENVIRONMENT_ID.to_string()), - environments: HashMap::from([( + environments: RwLock::new(HashMap::from([( LOCAL_ENVIRONMENT_ID.to_string(), Arc::new(Environment::default_for_tests()), - )]), + )])), local_environment: Arc::new(Environment::default_for_tests()), } } @@ -77,7 +78,7 @@ impl EnvironmentManager { pub fn disabled_for_tests(local_runtime_paths: ExecServerRuntimePaths) -> Self { Self { default_environment: None, - environments: HashMap::new(), + environments: RwLock::new(HashMap::new()), local_environment: Arc::new(Environment::local(local_runtime_paths)), } } @@ -196,7 +197,7 @@ impl EnvironmentManager { }; Ok(Self { default_environment, - environments: environment_map, + environments: RwLock::new(environment_map), local_environment, }) } @@ -218,10 +219,14 @@ impl EnvironmentManager { let Some(default_environment_id) = self.default_environment.as_ref() else { return Vec::new(); }; - let mut environment_ids = Vec::with_capacity(self.environments.len()); + let environments = self + .environments + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut environment_ids = Vec::with_capacity(environments.len()); environment_ids.push(default_environment_id.clone()); environment_ids.extend( - self.environments + environments .keys() .filter(|environment_id| *environment_id != default_environment_id) .cloned(), @@ -236,7 +241,45 @@ impl EnvironmentManager { /// Returns a named environment instance. pub fn get_environment(&self, environment_id: &str) -> Option> { - self.environments.get(environment_id).cloned() + self.environments + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(environment_id) + .cloned() + } + + /// Adds or replaces a named remote environment without changing the + /// manager's default environment selection. + pub fn upsert_environment( + &self, + environment_id: String, + exec_server_url: String, + ) -> Result<(), ExecServerError> { + if environment_id.is_empty() { + return Err(ExecServerError::Protocol( + "environment id cannot be empty".to_string(), + )); + } + let (exec_server_url, disabled) = normalize_exec_server_url(Some(exec_server_url)); + if disabled { + return Err(ExecServerError::Protocol( + "remote environment cannot use disabled exec-server url".to_string(), + )); + } + let Some(exec_server_url) = exec_server_url else { + return Err(ExecServerError::Protocol( + "remote environment requires an exec-server url".to_string(), + )); + }; + let environment = Environment::remote_inner( + exec_server_url, + self.local_environment.local_runtime_paths.clone(), + ); + self.environments + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert(environment_id, Arc::new(environment)); + Ok(()) } } @@ -717,6 +760,45 @@ mod tests { assert!(manager.get_environment("does-not-exist").is_none()); } + #[tokio::test] + async fn environment_manager_upserts_named_remote_environment() { + let manager = EnvironmentManager::disabled_for_tests(test_runtime_paths()); + + manager + .upsert_environment("executor-a".to_string(), "ws://127.0.0.1:8765".to_string()) + .expect("remote environment"); + let first = manager + .get_environment("executor-a") + .expect("first remote environment"); + assert!(first.is_remote()); + assert_eq!(first.exec_server_url(), Some("ws://127.0.0.1:8765")); + assert_eq!(manager.default_environment_id(), None); + + manager + .upsert_environment("executor-a".to_string(), "ws://127.0.0.1:9876".to_string()) + .expect("updated remote environment"); + let second = manager + .get_environment("executor-a") + .expect("second remote environment"); + assert!(second.is_remote()); + assert_eq!(second.exec_server_url(), Some("ws://127.0.0.1:9876")); + assert!(!Arc::ptr_eq(&first, &second)); + } + + #[tokio::test] + async fn environment_manager_rejects_empty_remote_environment_url() { + let manager = EnvironmentManager::disabled_for_tests(test_runtime_paths()); + + let err = manager + .upsert_environment("executor-a".to_string(), String::new()) + .expect_err("empty URL should fail"); + + assert_eq!( + err.to_string(), + "exec-server protocol error: remote environment requires an exec-server url" + ); + } + #[tokio::test] async fn default_environment_has_ready_local_executor() { let environment = Environment::default_for_tests(); diff --git a/codex-rs/exec-server/src/remote.rs b/codex-rs/exec-server/src/remote.rs index b574ced72f..43c424a142 100644 --- a/codex-rs/exec-server/src/remote.rs +++ b/codex-rs/exec-server/src/remote.rs @@ -1,16 +1,11 @@ -use std::collections::BTreeMap; use std::env; use std::time::Duration; use reqwest::StatusCode; use serde::Deserialize; -use serde::Serialize; -use serde_json::Value; -use sha2::Digest as _; use tokio::time::sleep; use tokio_tungstenite::connect_async; use tracing::warn; -use uuid::Uuid; use crate::ExecServerError; use crate::ExecServerRuntimePaths; @@ -20,7 +15,6 @@ use crate::server::ConnectionProcessor; pub const CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN_ENV_VAR: &str = "CODEX_EXEC_SERVER_REMOTE_BEARER_TOKEN"; -const PROTOCOL_VERSION: &str = "codex-exec-server-v1"; const ERROR_BODY_PREVIEW_BYTES: usize = 4096; #[derive(Clone)] @@ -51,28 +45,27 @@ impl ExecutorRegistryClient { async fn register_executor( &self, - request: &ExecutorRegistryRegisterExecutorRequest, + executor_id: &str, ) -> Result { - self.post_json( - &format!("/cloud/executor/{}/register", request.executor_id), - request, - ) - .await - } - - async fn post_json(&self, path: &str, request: &T) -> Result - where - T: Serialize + Sync, - R: for<'de> Deserialize<'de>, - { let response = self .http - .post(endpoint_url(&self.base_url, path)) + .post(endpoint_url( + &self.base_url, + &format!("/cloud/executor/{executor_id}/register"), + )) .bearer_auth(&self.bearer_token) - .json(request) .send() .await?; + self.parse_json_response(response).await + } + async fn parse_json_response( + &self, + response: reqwest::Response, + ) -> Result + where + R: for<'de> Deserialize<'de>, + { if response.status().is_success() { return response.json::().await.map_err(ExecServerError::from); } @@ -87,19 +80,8 @@ impl ExecutorRegistryClient { } } -#[derive(Debug, Clone, Eq, PartialEq, Serialize)] -struct ExecutorRegistryRegisterExecutorRequest { - idempotency_id: String, - executor_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - name: Option, - labels: BTreeMap, - metadata: Value, -} - #[derive(Debug, Clone, Eq, PartialEq, Deserialize)] struct ExecutorRegistryExecutorRegistrationResponse { - id: String, executor_id: String, url: String, } @@ -143,32 +125,6 @@ impl RemoteExecutorConfig { bearer_token, }) } - - fn registration_request( - &self, - registration_id: Uuid, - ) -> ExecutorRegistryRegisterExecutorRequest { - ExecutorRegistryRegisterExecutorRequest { - idempotency_id: self.default_idempotency_id(registration_id), - executor_id: self.executor_id.clone(), - name: Some(self.name.clone()), - labels: BTreeMap::new(), - metadata: Value::Object(Default::default()), - } - } - - fn default_idempotency_id(&self, registration_id: Uuid) -> String { - let mut hasher = sha2::Sha256::new(); - hasher.update(self.executor_id.as_bytes()); - hasher.update(b"\0"); - hasher.update(self.name.as_bytes()); - hasher.update(b"\0"); - hasher.update(PROTOCOL_VERSION); - hasher.update(b"\0"); - hasher.update(registration_id.as_bytes()); - let digest = hasher.finalize(); - format!("codex-exec-server-{digest:x}") - } } /// Register an exec-server for remote use and serve requests over the returned @@ -179,15 +135,13 @@ pub async fn run_remote_executor( ) -> Result<(), ExecServerError> { let client = ExecutorRegistryClient::new(config.base_url.clone(), config.bearer_token.clone())?; let processor = ConnectionProcessor::new(runtime_paths); - let registration_id = Uuid::new_v4(); let mut backoff = Duration::from_secs(1); loop { - let request = config.registration_request(registration_id); - let response = client.register_executor(&request).await?; + let response = client.register_executor(&config.executor_id).await?; eprintln!( - "codex exec-server remote executor {} registered with executor_id {}", - response.id, response.executor_id + "codex exec-server remote executor registered with executor_id {}", + response.executor_id ); match connect_async(response.url.as_str()).await { @@ -323,11 +277,9 @@ fn preview_error_body(body: &str) -> Option { #[cfg(test)] mod tests { use pretty_assertions::assert_eq; - use serde_json::json; use wiremock::Mock; use wiremock::MockServer; use wiremock::ResponseTemplate; - use wiremock::matchers::body_json; use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; @@ -337,21 +289,16 @@ mod tests { #[tokio::test] async fn register_executor_posts_with_bearer_token_header() { let server = MockServer::start().await; - let registration_id = Uuid::from_u128(1); let config = RemoteExecutorConfig::with_bearer_token( server.uri(), "exec-requested".to_string(), "registry-token".to_string(), ) .expect("config"); - let request = config.registration_request(registration_id); - let expected_request = serde_json::to_value(&request).expect("serialize request"); Mock::given(method("POST")) .and(path("/cloud/executor/exec-requested/register")) .and(header("authorization", "Bearer registry-token")) - .and(body_json(expected_request)) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "id": "registration-1", + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "executor_id": "exec-1", "url": "wss://rendezvous.test/executor/exec-1?role=executor&sig=abc" }))) @@ -361,14 +308,13 @@ mod tests { .expect("client"); let response = client - .register_executor(&request) + .register_executor(&config.executor_id) .await .expect("register executor"); assert_eq!( response, ExecutorRegistryExecutorRegistrationResponse { - id: "registration-1".to_string(), executor_id: "exec-1".to_string(), url: "wss://rendezvous.test/executor/exec-1?role=executor&sig=abc".to_string(), } From faa5d4a5e28330a80b251f571ed3a03fae75fa87 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Fri, 8 May 2026 16:33:29 -0700 Subject: [PATCH 20/27] Increase exec-server environment transport timeouts (#21825) ## Why The environment-backed exec-server transport currently hardcodes 5 second connect and initialize timeouts in `client_transport.rs`. That is short for SSH-backed stdio environments and remote websocket environments, and there is currently no way to raise those values from `CODEX_HOME/environments.toml`. This stacked follow-up raises the default environment transport timeouts and lets each configured environment override them in `environments.toml`. ## What Changed - raise the default environment transport connect and initialize timeouts from 5s to 10s - store concrete timeout values on `ExecServerTransportParams` instead of hardcoding them in `connect_for_transport(...)` - add `connect_timeout_sec` and `initialize_timeout_sec` to `[[environments]]` entries in `environments.toml` - apply parse-time defaults so runtime transport code receives fully resolved timeout values - reject `connect_timeout_sec` on stdio environments because it only applies to websocket transports - extend parser tests to cover the new fields and defaults ## Stack - base: https://github.com/openai/codex/pull/21794 - this PR: configurable environment transport timeouts ## Validation - `cd /Users/starr/code/codex-worktrees/exec-env-timeouts-config-20260508/codex-rs && just fmt` - not run: tests --------- Co-authored-by: Codex --- codex-rs/exec-server/src/client.rs | 23 ++-- codex-rs/exec-server/src/client_api.rs | 24 +++- codex-rs/exec-server/src/client_transport.rs | 21 +-- codex-rs/exec-server/src/environment.rs | 11 +- codex-rs/exec-server/src/environment_toml.rs | 136 +++++++++++++++++-- 5 files changed, 176 insertions(+), 39 deletions(-) diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index ff3cf37904..9261a59542 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -895,6 +895,8 @@ mod tests { use super::ExecServerClientConnectOptions; use crate::ProcessId; #[cfg(not(windows))] + use crate::client_api::DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT; + #[cfg(not(windows))] use crate::client_api::ExecServerTransportParams; use crate::client_api::StdioExecServerCommand; use crate::client_api::StdioExecServerConnectArgs; @@ -962,15 +964,18 @@ mod tests { #[tokio::test] async fn connect_for_transport_initializes_stdio_command() { let client = ExecServerClient::connect_for_transport( - ExecServerTransportParams::StdioCommand(StdioExecServerCommand { - program: "sh".to_string(), - args: vec![ - "-c".to_string(), - "read _line; printf '%s\\n' '{\"id\":1,\"result\":{\"sessionId\":\"stdio-test\"}}'; read _line; sleep 60".to_string(), - ], - env: HashMap::new(), - cwd: None, - }), + ExecServerTransportParams::StdioCommand { + command: StdioExecServerCommand { + program: "sh".to_string(), + args: vec![ + "-c".to_string(), + "read _line; printf '%s\\n' '{\"id\":1,\"result\":{\"sessionId\":\"stdio-test\"}}'; read _line; sleep 60".to_string(), + ], + env: HashMap::new(), + cwd: None, + }, + initialize_timeout: DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT, + }, ) .await .expect("stdio transport should connect"); diff --git a/codex-rs/exec-server/src/client_api.rs b/codex-rs/exec-server/src/client_api.rs index 8adfadd6e7..899863723f 100644 --- a/codex-rs/exec-server/src/client_api.rs +++ b/codex-rs/exec-server/src/client_api.rs @@ -9,6 +9,9 @@ use crate::HttpRequestParams; use crate::HttpRequestResponse; use crate::HttpResponseBodyStream; +pub(crate) const DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +pub(crate) const DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT: Duration = Duration::from_secs(10); + /// Connection options for any exec-server client transport. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExecServerClientConnectOptions { @@ -48,9 +51,26 @@ pub(crate) struct StdioExecServerCommand { /// Parameters used to connect to a remote exec-server environment. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum ExecServerTransportParams { - WebSocketUrl(String), + WebSocketUrl { + websocket_url: String, + connect_timeout: Duration, + initialize_timeout: Duration, + }, #[allow(dead_code)] - StdioCommand(StdioExecServerCommand), + StdioCommand { + command: StdioExecServerCommand, + initialize_timeout: Duration, + }, +} + +impl ExecServerTransportParams { + pub(crate) fn websocket_url(websocket_url: String) -> Self { + Self::WebSocketUrl { + websocket_url, + connect_timeout: DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT, + initialize_timeout: DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT, + } + } } /// Sends HTTP requests through a runtime-selected transport. diff --git a/codex-rs/exec-server/src/client_transport.rs b/codex-rs/exec-server/src/client_transport.rs index 3fccfa25c5..23dc0bc7b3 100644 --- a/codex-rs/exec-server/src/client_transport.rs +++ b/codex-rs/exec-server/src/client_transport.rs @@ -1,6 +1,4 @@ use std::process::Stdio; -use std::time::Duration; - use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; use tokio::process::Command; @@ -17,29 +15,34 @@ use crate::client_api::StdioExecServerConnectArgs; use crate::connection::JsonRpcConnection; const ENVIRONMENT_CLIENT_NAME: &str = "codex-environment"; -const ENVIRONMENT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); -const ENVIRONMENT_INITIALIZE_TIMEOUT: Duration = Duration::from_secs(5); impl ExecServerClient { pub(crate) async fn connect_for_transport( transport_params: crate::client_api::ExecServerTransportParams, ) -> Result { match transport_params { - crate::client_api::ExecServerTransportParams::WebSocketUrl(websocket_url) => { + crate::client_api::ExecServerTransportParams::WebSocketUrl { + websocket_url, + connect_timeout, + initialize_timeout, + } => { Self::connect_websocket(RemoteExecServerConnectArgs { websocket_url, client_name: ENVIRONMENT_CLIENT_NAME.to_string(), - connect_timeout: ENVIRONMENT_CONNECT_TIMEOUT, - initialize_timeout: ENVIRONMENT_INITIALIZE_TIMEOUT, + connect_timeout, + initialize_timeout, resume_session_id: None, }) .await } - crate::client_api::ExecServerTransportParams::StdioCommand(command) => { + crate::client_api::ExecServerTransportParams::StdioCommand { + command, + initialize_timeout, + } => { Self::connect_stdio_command(StdioExecServerConnectArgs { command, client_name: ENVIRONMENT_CLIENT_NAME.to_string(), - initialize_timeout: ENVIRONMENT_INITIALIZE_TIMEOUT, + initialize_timeout, resume_session_id: None, }) .await diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 049a2d0229..7e4a3fb056 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -373,7 +373,7 @@ impl Environment { local_runtime_paths: Option, ) -> Self { Self::remote_with_transport( - ExecServerTransportParams::WebSocketUrl(exec_server_url), + ExecServerTransportParams::websocket_url(exec_server_url), local_runtime_paths, ) } @@ -383,10 +383,11 @@ impl Environment { local_runtime_paths: Option, ) -> Self { let exec_server_url = match &remote_transport { - ExecServerTransportParams::WebSocketUrl(exec_server_url) => { - Some(exec_server_url.clone()) - } - ExecServerTransportParams::StdioCommand(_) => None, + ExecServerTransportParams::WebSocketUrl { + websocket_url: exec_server_url, + .. + } => Some(exec_server_url.clone()), + ExecServerTransportParams::StdioCommand { .. } => None, }; let client = LazyRemoteExecServerClient::new(remote_transport.clone()); let exec_backend: Arc = Arc::new(RemoteProcess::new(client.clone())); diff --git a/codex-rs/exec-server/src/environment_toml.rs b/codex-rs/exec-server/src/environment_toml.rs index 2f5fd97790..90f4c78262 100644 --- a/codex-rs/exec-server/src/environment_toml.rs +++ b/codex-rs/exec-server/src/environment_toml.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::collections::HashSet; use std::path::Path; use std::path::PathBuf; +use std::time::Duration; use async_trait::async_trait; use serde::Deserialize; @@ -11,6 +12,8 @@ use crate::DefaultEnvironmentProvider; use crate::Environment; use crate::EnvironmentProvider; use crate::ExecServerError; +use crate::client_api::DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT; +use crate::client_api::DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT; use crate::client_api::ExecServerTransportParams; use crate::client_api::StdioExecServerCommand; use crate::environment::LOCAL_ENVIRONMENT_ID; @@ -38,6 +41,10 @@ struct EnvironmentToml { args: Option>, env: Option>, cwd: Option, + #[serde(default, with = "option_duration_secs")] + connect_timeout_sec: Option, + #[serde(default, with = "option_duration_secs")] + initialize_timeout_sec: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -108,6 +115,8 @@ fn parse_environment_toml( args, env, cwd, + connect_timeout_sec, + initialize_timeout_sec, } = item; validate_environment_id(&id)?; if program.is_none() && (args.is_some() || env.is_some() || cwd.is_some()) { @@ -115,11 +124,24 @@ fn parse_environment_toml( "environment `{id}` args, env, and cwd require program" ))); } + if url.is_none() && connect_timeout_sec.is_some() { + return Err(ExecServerError::Protocol(format!( + "environment `{id}` connect_timeout_sec requires url" + ))); + } + + let connect_timeout = connect_timeout_sec.unwrap_or(DEFAULT_REMOTE_EXEC_SERVER_CONNECT_TIMEOUT); + let initialize_timeout = + initialize_timeout_sec.unwrap_or(DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT); let transport_params = match (url, program) { (Some(url), None) => { let url = validate_websocket_url(url)?; - ExecServerTransportParams::WebSocketUrl(url) + ExecServerTransportParams::WebSocketUrl { + websocket_url: url, + connect_timeout, + initialize_timeout, + } } (None, Some(program)) => { let program = program.trim().to_string(); @@ -129,12 +151,15 @@ fn parse_environment_toml( ))); } let cwd = normalize_stdio_cwd(&id, cwd, config_dir)?; - ExecServerTransportParams::StdioCommand(StdioExecServerCommand { - program, - args: args.unwrap_or_default(), - env: env.unwrap_or_default(), - cwd, - }) + ExecServerTransportParams::StdioCommand { + command: StdioExecServerCommand { + program, + args: args.unwrap_or_default(), + env: env.unwrap_or_default(), + cwd, + }, + initialize_timeout, + } } (None, None) | (Some(_), Some(_)) => { return Err(ExecServerError::Protocol(format!( @@ -278,6 +303,22 @@ fn load_environments_toml(path: &Path) -> Result(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + let secs = Option::::deserialize(deserializer)?; + secs.map(|secs| Duration::try_from_secs_f64(secs).map_err(serde::de::Error::custom)) + .transpose() + } +} + #[cfg(test)] mod tests { use pretty_assertions::assert_eq; @@ -424,6 +465,15 @@ mod tests { }, "environment `devbox` args, env, and cwd require program", ), + ( + EnvironmentToml { + id: "ssh-dev".to_string(), + program: Some("ssh".to_string()), + connect_timeout_sec: Some(Duration::from_secs(1)), + ..Default::default() + }, + "environment `ssh-dev` connect_timeout_sec requires url", + ), ]; for (item, expected) in cases { @@ -459,12 +509,59 @@ mod tests { assert_eq!( provider.environments[0].1, - ExecServerTransportParams::StdioCommand(StdioExecServerCommand { - program: "ssh".to_string(), - args: Vec::new(), - env: HashMap::new(), - cwd: Some(config_dir.path().join("workspace")), - }) + ExecServerTransportParams::StdioCommand { + command: StdioExecServerCommand { + program: "ssh".to_string(), + args: Vec::new(), + env: HashMap::new(), + cwd: Some(config_dir.path().join("workspace")), + }, + initialize_timeout: DEFAULT_REMOTE_EXEC_SERVER_INITIALIZE_TIMEOUT, + } + ); + } + + #[test] + fn toml_provider_parses_configured_transport_timeouts() { + let provider = TomlEnvironmentProvider::new(EnvironmentsToml { + default: None, + environments: vec![ + EnvironmentToml { + id: "devbox".to_string(), + url: Some("ws://127.0.0.1:8765".to_string()), + connect_timeout_sec: Some(Duration::from_secs(12)), + initialize_timeout_sec: Some(Duration::from_secs(34)), + ..Default::default() + }, + EnvironmentToml { + id: "ssh-dev".to_string(), + program: Some("ssh".to_string()), + initialize_timeout_sec: Some(Duration::from_secs(56)), + ..Default::default() + }, + ], + }) + .expect("provider"); + + assert_eq!( + provider.environments[0].1, + ExecServerTransportParams::WebSocketUrl { + websocket_url: "ws://127.0.0.1:8765".to_string(), + connect_timeout: Duration::from_secs(12), + initialize_timeout: Duration::from_secs(34), + } + ); + assert_eq!( + provider.environments[1].1, + ExecServerTransportParams::StdioCommand { + command: StdioExecServerCommand { + program: "ssh".to_string(), + args: Vec::new(), + env: HashMap::new(), + cwd: None, + }, + initialize_timeout: Duration::from_secs(56), + } ); } @@ -559,6 +656,8 @@ default = "ssh-dev" [[environments]] id = "devbox" url = "ws://127.0.0.1:4512" +connect_timeout_sec = 12.0 +initialize_timeout_sec = 34.0 [[environments]] id = "ssh-dev" @@ -575,7 +674,16 @@ CODEX_LOG = "debug" assert_eq!(environments.default.as_deref(), Some("ssh-dev")); assert_eq!(environments.environments.len(), 2); - assert_eq!(environments.environments[0].id, "devbox"); + assert_eq!( + environments.environments[0], + EnvironmentToml { + id: "devbox".to_string(), + url: Some("ws://127.0.0.1:4512".to_string()), + connect_timeout_sec: Some(Duration::from_secs(12)), + initialize_timeout_sec: Some(Duration::from_secs(34)), + ..Default::default() + } + ); assert_eq!( environments.environments[1], EnvironmentToml { From 0c8d42525effe7c0208fcfe052a5aece8941cc17 Mon Sep 17 00:00:00 2001 From: Ruslan Nigmatullin Date: Fri, 8 May 2026 16:51:16 -0700 Subject: [PATCH 21/27] [daemon] Add app-server daemon lifecycle management (#20718) ## Why Desktop and mobile Codex clients need a machine-readable way to bootstrap and manage `codex app-server` on remote machines reached over SSH. The same flow is also useful for bringing up app-server with `remote_control` enabled on a fresh developer machine and keeping that managed install current without requiring a human session. ## What changed - add the new experimental `codex-app-server-daemon` crate and wire it into `codex app-server daemon` lifecycle commands: `start`, `restart`, `stop`, `version`, and `bootstrap` - add explicit `enable-remote-control` and `disable-remote-control` commands that persist the launch setting and restart a running managed daemon so the change takes effect immediately - emit JSON success responses for daemon commands so remote callers can consume them directly - support a Unix-only pidfile-backed detached backend for lifecycle management - assume the standalone `install.sh` layout for daemon-managed binaries and always launch `CODEX_HOME/packages/standalone/current/codex` - add bootstrap support for the standalone managed install plus a detached hourly updater loop - harden lifecycle management around concurrent operations, pidfile ownership, stale state cleanup, updater ownership, managed-binary preflight, Unix-only rejection, forced shutdown after the graceful window, and updater process-group tracking/cleanup - document the experimental Unix-only support boundary plus the standalone bootstrap/update flow in `codex-rs/app-server-daemon/README.md` ## Verification - `cargo test -p codex-app-server-daemon -p codex-cli` - live pid validation on `cb4`: `bootstrap --remote-control`, `restart`, `version`, `stop` ## Follow-up - Add updater self-refresh so the long-lived `pid-update-loop` can replace its own executable image after installing a newer managed Codex binary. --- codex-rs/Cargo.lock | 21 + codex-rs/Cargo.toml | 2 + codex-rs/app-server-daemon/BUILD.bazel | 6 + codex-rs/app-server-daemon/Cargo.toml | 39 ++ codex-rs/app-server-daemon/README.md | 104 +++ codex-rs/app-server-daemon/src/backend/mod.rs | 33 + codex-rs/app-server-daemon/src/backend/pid.rs | 600 +++++++++++++++++ .../src/backend/pid_tests.rs | 158 +++++ codex-rs/app-server-daemon/src/client.rs | 131 ++++ codex-rs/app-server-daemon/src/lib.rs | 630 ++++++++++++++++++ .../app-server-daemon/src/managed_install.rs | 66 ++ .../src/managed_install_tests.rs | 16 + codex-rs/app-server-daemon/src/settings.rs | 63 ++ codex-rs/app-server-daemon/src/update_loop.rs | 132 ++++ codex-rs/cli/Cargo.toml | 1 + codex-rs/cli/src/main.rs | 188 ++++++ 16 files changed, 2190 insertions(+) create mode 100644 codex-rs/app-server-daemon/BUILD.bazel create mode 100644 codex-rs/app-server-daemon/Cargo.toml create mode 100644 codex-rs/app-server-daemon/README.md create mode 100644 codex-rs/app-server-daemon/src/backend/mod.rs create mode 100644 codex-rs/app-server-daemon/src/backend/pid.rs create mode 100644 codex-rs/app-server-daemon/src/backend/pid_tests.rs create mode 100644 codex-rs/app-server-daemon/src/client.rs create mode 100644 codex-rs/app-server-daemon/src/lib.rs create mode 100644 codex-rs/app-server-daemon/src/managed_install.rs create mode 100644 codex-rs/app-server-daemon/src/managed_install_tests.rs create mode 100644 codex-rs/app-server-daemon/src/settings.rs create mode 100644 codex-rs/app-server-daemon/src/update_loop.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d7fce5bd7a..8ea52e9304 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1979,6 +1979,26 @@ dependencies = [ "url", ] +[[package]] +name = "codex-app-server-daemon" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-app-server-protocol", + "codex-app-server-transport", + "codex-core", + "codex-uds", + "futures", + "libc", + "pretty_assertions", + "reqwest", + "serde", + "serde_json", + "tempfile", + "tokio", + "tokio-tungstenite", +] + [[package]] name = "codex-app-server-protocol" version = "0.0.0" @@ -2206,6 +2226,7 @@ dependencies = [ "clap", "clap_complete", "codex-app-server", + "codex-app-server-daemon", "codex-app-server-protocol", "codex-app-server-test-client", "codex-arg0", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index adbcdb14ea..9e203e9f8a 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -11,6 +11,7 @@ members = [ "async-utils", "app-server", "app-server-transport", + "app-server-daemon", "app-server-client", "app-server-protocol", "app-server-test-client", @@ -131,6 +132,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" } 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..5085d6bd7c --- /dev/null +++ b/codex-rs/app-server-daemon/Cargo.toml @@ -0,0 +1,39 @@ +[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-core = { 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 } +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..c343d5c849 --- /dev/null +++ b/codex-rs/app-server-daemon/README.md @@ -0,0 +1,104 @@ +# 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. After a successful fetch, it restarts a currently running app-server only when the managed binary reports a different version. | +| Some other tool updates the managed binary path | The next fresh start or restart uses the updated file at that path | No | Not automatically. The existing process keeps the old executable image until an explicit `restart`. | + +### 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`, then restarts app-server if it is running on a different version +- 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 a binary that the daemon would use on its next launch: + +- a currently running app-server remains on the old executable image +- `restart` will launch the updated binary +- for bootstrapped daemons, the detached updater loop only reacts to updates it + fetched itself; it does not watch arbitrary file replacement + +## 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. + +`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..053bcdc354 --- /dev/null +++ b/codex-rs/app-server-daemon/src/lib.rs @@ -0,0 +1,630 @@ +mod backend; +mod client; +mod managed_install; +mod settings; +mod update_loop; + +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_core::config::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, 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)] +pub(crate) enum RestartIfRunningOutcome { + Completed, + Busy, +} + +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 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) -> 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?; + if let Some(backend) = self.running_backend_instance(&settings).await? { + let Ok(info) = client::probe(&self.socket_path).await else { + return Ok(RestartIfRunningOutcome::Completed); + }; + let managed_version = managed_codex_version(&self.managed_codex_bin).await?; + if info.app_server_version == managed_version { + return Ok(RestartIfRunningOutcome::Completed); + } + backend.stop().await?; + let _ = self.start_managed_backend(&settings).await?; + self.wait_until_ready().await?; + return Ok(RestartIfRunningOutcome::Completed); + } + + 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(RestartIfRunningOutcome::Completed) + } + + 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 set_remote_control(&self, mode: RemoteControlMode) -> Result { + let _operation_lock = self.acquire_operation_lock().await?; + 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> { + let backend = backend::pid_backend(self.backend_paths(settings)); + backend.start().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 { + BackendPaths { + codex_bin: self.managed_codex_bin.clone(), + 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 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::BootstrapStatus; + use super::LifecycleStatus; + use super::RemoteControlStatus; + + #[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\"" + ); + } +} 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..83debb24e5 --- /dev/null +++ b/codex-rs/app-server-daemon/src/managed_install.rs @@ -0,0 +1,66 @@ +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 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 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) +} + +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..b7d5cccc4f --- /dev/null +++ b/codex-rs/app-server-daemon/src/managed_install_tests.rs @@ -0,0 +1,16 @@ +use pretty_assertions::assert_eq; + +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()); +} 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..91193e0af5 --- /dev/null +++ b/codex-rs/app-server-daemon/src/update_loop.rs @@ -0,0 +1,132 @@ +#[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 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)] +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")?; + if sleep_or_terminate(INITIAL_UPDATE_DELAY, &mut terminate).await { + return Ok(()); + } + loop { + match update_once(&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(terminate: &mut Signal) -> Result { + install_latest_standalone().await?; + + let daemon = Daemon::from_environment()?; + loop { + if terminate.recv().now_or_never().flatten().is_some() { + return Ok(UpdateLoopControl::Stop); + } + match daemon.try_restart_if_running().await? { + RestartIfRunningOutcome::Completed => return Ok(UpdateLoopControl::Continue), + RestartIfRunningOutcome::Busy => { + if sleep_or_terminate(RESTART_RETRY_INTERVAL, terminate).await { + return Ok(UpdateLoopControl::Stop); + } + } + } + } +} + +#[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}") + } +} diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index f2a289bf62..120d2e497a 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -22,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/main.rs b/codex-rs/cli/src/main.rs index dbe6b7605c..d70e401664 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; @@ -469,6 +472,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 +489,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 +530,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 @@ -875,6 +922,41 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { ) .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, @@ -1547,6 +1629,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 +1653,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, @@ -2524,6 +2634,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 = @@ -2552,6 +2726,20 @@ mod tests { assert!(err.to_string().contains("app-server proxy")); } + #[test] + fn reject_remote_auth_token_env_for_app_server_version() { + let subcommand = AppServerSubcommand::Daemon(AppServerDaemonCommand { + subcommand: AppServerDaemonSubcommand::Version, + }); + let err = reject_remote_mode_for_app_server_subcommand( + /*remote*/ None, + Some("CODEX_REMOTE_AUTH_TOKEN"), + Some(&subcommand), + ) + .expect_err("app-server daemon version should reject --remote-auth-token-env"); + assert!(err.to_string().contains("app-server daemon version")); + } + #[test] fn app_server_capability_token_flags_parse() { let app_server = app_server_from_args( From bd42660cb48155206286ade3ec5030418cf4d3e7 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Fri, 8 May 2026 16:58:41 -0700 Subject: [PATCH 22/27] feat: add Bedrock Mantle client agent header (#21840) ## Why Amazon Bedrock Mantle needs a stable client-agent header so requests from the built-in Bedrock provider can be identified as coming from Codex for safety stack. ## What changed - Added `x-amzn-mantle-client-agent: codex` to the built-in Amazon Bedrock provider default HTTP headers. --- codex-rs/model-provider-info/src/lib.rs | 7 ++++++- .../src/model_provider_info_tests.rs | 20 ++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/codex-rs/model-provider-info/src/lib.rs b/codex-rs/model-provider-info/src/lib.rs index 6fca7e6a1f..6856b80b71 100644 --- a/codex-rs/model-provider-info/src/lib.rs +++ b/codex-rs/model-provider-info/src/lib.rs @@ -39,6 +39,8 @@ const AMAZON_BEDROCK_PROVIDER_NAME: &str = "Amazon Bedrock"; pub const AMAZON_BEDROCK_PROVIDER_ID: &str = "amazon-bedrock"; pub const AMAZON_BEDROCK_DEFAULT_BASE_URL: &str = "https://bedrock-mantle.us-east-1.api.aws/openai/v1"; +const AMAZON_BEDROCK_MANTLE_CLIENT_AGENT_HEADER: &str = "x-amzn-mantle-client-agent"; +const AMAZON_BEDROCK_MANTLE_CLIENT_AGENT_VALUE: &str = "codex"; const CHAT_WIRE_API_REMOVED_ERROR: &str = "`wire_api = \"chat\"` is no longer supported.\nHow to fix: set `wire_api = \"responses\"` in your provider config.\nMore info: https://github.com/openai/codex/discussions/7782"; pub const LEGACY_OLLAMA_CHAT_PROVIDER_ID: &str = "ollama-chat"; pub const OLLAMA_CHAT_PROVIDER_REMOVED_ERROR: &str = "`ollama-chat` is no longer supported.\nHow to fix: replace `ollama-chat` with `ollama` in `model_provider`, `oss_provider`, or `--local-provider`.\nMore info: https://github.com/openai/codex/discussions/7782"; @@ -365,7 +367,10 @@ impl ModelProviderInfo { })), wire_api: WireApi::Responses, query_params: None, - http_headers: None, + http_headers: Some(HashMap::from([( + AMAZON_BEDROCK_MANTLE_CLIENT_AGENT_HEADER.to_string(), + AMAZON_BEDROCK_MANTLE_CLIENT_AGENT_VALUE.to_string(), + )])), env_http_headers: None, request_max_retries: None, stream_max_retries: None, diff --git a/codex-rs/model-provider-info/src/model_provider_info_tests.rs b/codex-rs/model-provider-info/src/model_provider_info_tests.rs index 54d325c131..abfa40a36a 100644 --- a/codex-rs/model-provider-info/src/model_provider_info_tests.rs +++ b/codex-rs/model-provider-info/src/model_provider_info_tests.rs @@ -256,7 +256,10 @@ fn test_create_amazon_bedrock_provider() { }), wire_api: WireApi::Responses, query_params: None, - http_headers: None, + http_headers: Some(maplit::hashmap! { + AMAZON_BEDROCK_MANTLE_CLIENT_AGENT_HEADER.to_string() => + AMAZON_BEDROCK_MANTLE_CLIENT_AGENT_VALUE.to_string(), + }), env_http_headers: None, request_max_retries: None, stream_max_retries: None, @@ -268,6 +271,21 @@ fn test_create_amazon_bedrock_provider() { ); } +#[test] +fn test_amazon_bedrock_provider_adds_mantle_client_agent_header() { + let api_provider = ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None) + .to_api_provider(/*auth_mode*/ None) + .expect("Amazon Bedrock provider should build API provider"); + + assert_eq!( + api_provider + .headers + .get(AMAZON_BEDROCK_MANTLE_CLIENT_AGENT_HEADER) + .and_then(|value| value.to_str().ok()), + Some(AMAZON_BEDROCK_MANTLE_CLIENT_AGENT_VALUE) + ); +} + #[test] fn test_built_in_model_providers_include_amazon_bedrock() { let providers = built_in_model_providers(/*openai_base_url*/ None); From 95ca27637330722a2cc915499fa9c1204ddfc397 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Fri, 8 May 2026 17:29:44 -0700 Subject: [PATCH 23/27] sqlite: no more destructive version bumps (#21847) ## Why We'd like SQLite state to become required and load-bearing. As a first step, let's remove the mechanism that allows us to blow away the SQLite DB on a version bump, and instead rely on graceful migrations. The original motivation ([PR](https://github.com/openai/codex/pull/10623)) behind this mechanism was to care less about backwards compatibility while SQLite was being landed, but I'd say it's quite important now to keep the data in it. ## What changed - Make `STATE_DB_FILENAME` and `LOGS_DB_FILENAME` the full canonical filenames: `state_5.sqlite` and `logs_2.sqlite`. - Remove `STATE_DB_VERSION` / `LOGS_DB_VERSION` and the helper that constructed filenames from versions. - Stop `StateRuntime::init` from scanning for or deleting older SQLite DB filenames at startup. - Delete the tests that encoded legacy state/logs DB deletion behavior. ## Verification - `cargo test -p codex-state` --- codex-rs/state/src/lib.rs | 6 +- codex-rs/state/src/runtime.rs | 109 +------------------------ codex-rs/state/src/runtime/backfill.rs | 78 ------------------ codex-rs/state/src/runtime/logs.rs | 61 -------------- 4 files changed, 4 insertions(+), 250 deletions(-) diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index 84582370a5..ea9a2b089d 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -60,10 +60,8 @@ pub use runtime::state_db_path; /// Environment variable for overriding the SQLite state database home directory. pub const SQLITE_HOME_ENV: &str = "CODEX_SQLITE_HOME"; -pub const LOGS_DB_FILENAME: &str = "logs"; -pub const LOGS_DB_VERSION: u32 = 2; -pub const STATE_DB_FILENAME: &str = "state"; -pub const STATE_DB_VERSION: u32 = 5; +pub const LOGS_DB_FILENAME: &str = "logs_2.sqlite"; +pub const STATE_DB_FILENAME: &str = "state_5.sqlite"; /// Errors encountered during DB operations. Tags: [stage] pub const DB_ERROR_METRIC: &str = "codex.db.error"; diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index c8b4e7b98e..c683847492 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -6,12 +6,10 @@ use crate::AgentJobItemStatus; use crate::AgentJobProgress; use crate::AgentJobStatus; use crate::LOGS_DB_FILENAME; -use crate::LOGS_DB_VERSION; use crate::LogEntry; use crate::LogQuery; use crate::LogRow; use crate::STATE_DB_FILENAME; -use crate::STATE_DB_VERSION; use crate::SortKey; use crate::ThreadMetadata; use crate::ThreadMetadataBuilder; @@ -98,22 +96,6 @@ impl StateRuntime { tokio::fs::create_dir_all(&codex_home).await?; let state_migrator = runtime_state_migrator(); let logs_migrator = runtime_logs_migrator(); - let current_state_name = state_db_filename(); - let current_logs_name = logs_db_filename(); - remove_legacy_db_files( - &codex_home, - current_state_name.as_str(), - STATE_DB_FILENAME, - "state", - ) - .await; - remove_legacy_db_files( - &codex_home, - current_logs_name.as_str(), - LOGS_DB_FILENAME, - "logs", - ) - .await; let state_path = state_db_path(codex_home.as_path()); let logs_path = logs_db_path(codex_home.as_path()); let pool = match open_state_sqlite(&state_path, &state_migrator).await { @@ -190,12 +172,8 @@ async fn open_logs_sqlite(path: &Path, migrator: &Migrator) -> anyhow::Result String { - format!("{base_name}_{version}.sqlite") -} - pub fn state_db_filename() -> String { - db_filename(STATE_DB_FILENAME, STATE_DB_VERSION) + STATE_DB_FILENAME.to_string() } pub fn state_db_path(codex_home: &Path) -> PathBuf { @@ -203,96 +181,13 @@ pub fn state_db_path(codex_home: &Path) -> PathBuf { } pub fn logs_db_filename() -> String { - db_filename(LOGS_DB_FILENAME, LOGS_DB_VERSION) + LOGS_DB_FILENAME.to_string() } pub fn logs_db_path(codex_home: &Path) -> PathBuf { codex_home.join(logs_db_filename()) } -async fn remove_legacy_db_files( - codex_home: &Path, - current_name: &str, - base_name: &str, - db_label: &str, -) { - let mut entries = match tokio::fs::read_dir(codex_home).await { - Ok(entries) => entries, - Err(err) => { - warn!( - "failed to read codex_home for {db_label} db cleanup {}: {err}", - codex_home.display(), - ); - return; - } - }; - let mut legacy_paths = Vec::new(); - while let Ok(Some(entry)) = entries.next_entry().await { - if !entry - .file_type() - .await - .map(|file_type| file_type.is_file()) - .unwrap_or(false) - { - continue; - } - let file_name = entry.file_name(); - let file_name = file_name.to_string_lossy(); - if !should_remove_db_file(file_name.as_ref(), current_name, base_name) { - continue; - } - - legacy_paths.push(entry.path()); - } - - // On Windows, SQLite can keep the main database file undeletable until the - // matching `-wal` / `-shm` sidecars are removed. Remove the longest - // sidecar-style paths first so the main file is attempted last. - legacy_paths.sort_by_key(|path| std::cmp::Reverse(path.as_os_str().len())); - for legacy_path in legacy_paths { - let mut result = tokio::fs::remove_file(&legacy_path).await; - for _ in 0..3 { - if result.is_ok() { - break; - } - tokio::time::sleep(Duration::from_millis(25)).await; - result = tokio::fs::remove_file(&legacy_path).await; - } - if let Err(err) = result { - warn!( - "failed to remove legacy {db_label} db file {}: {err}", - legacy_path.display(), - ); - } - } -} - -fn should_remove_db_file(file_name: &str, current_name: &str, base_name: &str) -> bool { - let mut normalized_name = file_name; - for suffix in ["-wal", "-shm", "-journal"] { - if let Some(stripped) = file_name.strip_suffix(suffix) { - normalized_name = stripped; - break; - } - } - if normalized_name == current_name { - return false; - } - let unversioned_name = format!("{base_name}.sqlite"); - if normalized_name == unversioned_name { - return true; - } - - let Some(version_with_extension) = normalized_name.strip_prefix(&format!("{base_name}_")) - else { - return false; - }; - let Some(version_suffix) = version_with_extension.strip_suffix(".sqlite") else { - return false; - }; - !version_suffix.is_empty() && version_suffix.chars().all(|ch| ch.is_ascii_digit()) -} - #[cfg(test)] mod tests { use super::open_state_sqlite; diff --git a/codex-rs/state/src/runtime/backfill.rs b/codex-rs/state/src/runtime/backfill.rs index cac3163f0e..2bfec0a88f 100644 --- a/codex-rs/state/src/runtime/backfill.rs +++ b/codex-rs/state/src/runtime/backfill.rs @@ -122,88 +122,10 @@ ON CONFLICT(id) DO NOTHING #[cfg(test)] mod tests { use super::StateRuntime; - use super::state_db_filename; use super::test_support::unique_temp_dir; - use crate::STATE_DB_FILENAME; - use crate::STATE_DB_VERSION; use chrono::Utc; use pretty_assertions::assert_eq; - #[tokio::test] - async fn init_removes_legacy_state_db_files() { - let codex_home = unique_temp_dir(); - tokio::fs::create_dir_all(&codex_home) - .await - .expect("create codex_home"); - - let current_name = state_db_filename(); - let previous_version = STATE_DB_VERSION.saturating_sub(1); - let unversioned_name = format!("{STATE_DB_FILENAME}.sqlite"); - for suffix in ["", "-wal", "-shm", "-journal"] { - let path = codex_home.join(format!("{unversioned_name}{suffix}")); - tokio::fs::write(path, b"legacy") - .await - .expect("write legacy"); - let old_version_path = codex_home.join(format!( - "{STATE_DB_FILENAME}_{previous_version}.sqlite{suffix}" - )); - tokio::fs::write(old_version_path, b"old_version") - .await - .expect("write old version"); - } - let unrelated_path = codex_home.join("state.sqlite_backup"); - tokio::fs::write(&unrelated_path, b"keep") - .await - .expect("write unrelated"); - let numeric_path = codex_home.join("123"); - tokio::fs::write(&numeric_path, b"keep") - .await - .expect("write numeric"); - - let _runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) - .await - .expect("initialize runtime"); - - for suffix in ["", "-wal", "-shm", "-journal"] { - let legacy_path = codex_home.join(format!("{unversioned_name}{suffix}")); - assert_eq!( - tokio::fs::try_exists(&legacy_path) - .await - .expect("check legacy path"), - false - ); - let old_version_path = codex_home.join(format!( - "{STATE_DB_FILENAME}_{previous_version}.sqlite{suffix}" - )); - assert_eq!( - tokio::fs::try_exists(&old_version_path) - .await - .expect("check old version path"), - false - ); - } - assert_eq!( - tokio::fs::try_exists(codex_home.join(current_name)) - .await - .expect("check new db path"), - true - ); - assert_eq!( - tokio::fs::try_exists(&unrelated_path) - .await - .expect("check unrelated path"), - true - ); - assert_eq!( - tokio::fs::try_exists(&numeric_path) - .await - .expect("check numeric path"), - true - ); - - let _ = tokio::fs::remove_dir_all(codex_home).await; - } - #[tokio::test] async fn backfill_state_persists_progress_and_completion() { let codex_home = unique_temp_dir(); diff --git a/codex-rs/state/src/runtime/logs.rs b/codex-rs/state/src/runtime/logs.rs index 6c878db624..e21df25e90 100644 --- a/codex-rs/state/src/runtime/logs.rs +++ b/codex-rs/state/src/runtime/logs.rs @@ -698,67 +698,6 @@ mod tests { let _ = tokio::fs::remove_dir_all(codex_home).await; } - #[tokio::test] - async fn init_recreates_legacy_logs_db_when_log_version_changes() { - let codex_home = unique_temp_dir(); - tokio::fs::create_dir_all(&codex_home) - .await - .expect("create codex home"); - let legacy_logs_path = codex_home.join("logs_1.sqlite"); - let pool = SqlitePool::connect_with( - SqliteConnectOptions::new() - .filename(&legacy_logs_path) - .create_if_missing(true), - ) - .await - .expect("open legacy logs db"); - LOGS_MIGRATOR - .run(&pool) - .await - .expect("apply legacy logs schema"); - sqlx::query( - "INSERT INTO logs (ts, ts_nanos, level, target, feedback_log_body, module_path, file, line, thread_id, process_uuid, estimated_bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", - ) - .bind(1_i64) - .bind(0_i64) - .bind("INFO") - .bind("cli") - .bind("legacy-log-row") - .bind("mod") - .bind("main.rs") - .bind(7_i64) - .bind("thread-1") - .bind("proc-1") - .bind(16_i64) - .execute(&pool) - .await - .expect("insert legacy log row"); - pool.close().await; - drop(pool); - - let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) - .await - .expect("initialize runtime"); - - assert!( - !legacy_logs_path.exists(), - "legacy logs db should be removed when the version changes" - ); - assert!( - logs_db_path(codex_home.as_path()).exists(), - "current logs db should be recreated during init" - ); - assert!( - runtime - .query_logs(&LogQuery::default()) - .await - .expect("query recreated logs db") - .is_empty() - ); - - let _ = tokio::fs::remove_dir_all(codex_home).await; - } - #[tokio::test] async fn init_configures_logs_db_with_incremental_auto_vacuum() { let codex_home = unique_temp_dir(); From 408e6218ab7fadc192901ae28520471a4f990671 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 8 May 2026 17:41:15 -0700 Subject: [PATCH 24/27] Reapply "Move skills watcher to app-server" (#21652) ## Why PR #21460 reverted the earlier move of skills change watching from `codex-core` into app-server. This reapplies that boundary change so app-server owns client-facing `skills/changed` notifications and core no longer carries the watcher. ## What - Restore the app-server `SkillsWatcher` and register it from thread listener setup. - Remove the core-owned skills watcher and its core live-reload integration surface. - Restore app-server coverage for `skills/changed` notifications after a watched skill file changes. ## Validation - `cargo test -p codex-app-server --test all suite::v2::skills_list::skills_changed_notification_is_emitted_after_skill_change -- --exact --nocapture` - `cargo test -p codex-core --lib --no-run` --- .../app-server/src/bespoke_event_handling.rs | 8 - codex-rs/app-server/src/lib.rs | 1 + codex-rs/app-server/src/message_processor.rs | 4 + codex-rs/app-server/src/request_processors.rs | 1 + .../request_processors/thread_lifecycle.rs | 14 +- .../request_processors/thread_processor.rs | 6 +- .../src/request_processors/turn_processor.rs | 4 + codex-rs/app-server/src/skills_watcher.rs | 112 +++++++++++++ codex-rs/app-server/src/thread_state.rs | 5 + .../app-server/tests/suite/v2/skills_list.rs | 51 +++++- codex-rs/core/src/codex_delegate.rs | 1 - codex-rs/core/src/codex_thread.rs | 9 +- codex-rs/core/src/lib.rs | 1 - codex-rs/core/src/session/mod.rs | 34 +--- codex-rs/core/src/session/session.rs | 4 - codex-rs/core/src/session/tests.rs | 7 - .../core/src/session/tests/guardian_tests.rs | 2 - codex-rs/core/src/session/turn.rs | 1 - codex-rs/core/src/skills_watcher.rs | 125 -------------- codex-rs/core/src/state/service.rs | 2 - codex-rs/core/src/thread_manager.rs | 69 +------- codex-rs/core/tests/suite/live_reload.rs | 157 ------------------ codex-rs/core/tests/suite/mod.rs | 1 - .../core/tests/suite/realtime_conversation.rs | 3 + codex-rs/mcp-server/src/codex_tool_runner.rs | 1 - codex-rs/protocol/src/protocol.rs | 3 - codex-rs/rollout-trace/src/protocol_event.rs | 2 - codex-rs/rollout/src/policy.rs | 1 - 28 files changed, 210 insertions(+), 419 deletions(-) create mode 100644 codex-rs/app-server/src/skills_watcher.rs delete mode 100644 codex-rs/core/src/skills_watcher.rs delete mode 100644 codex-rs/core/tests/suite/live_reload.rs diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 1f2f289b05..b4348f53bd 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 => { diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index dea4a20e5a..6999948b04 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -94,6 +94,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; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 3853244958..c8204e05b6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -36,6 +36,7 @@ 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; @@ -314,6 +315,7 @@ impl MessageProcessor { 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_watch_manager = @@ -405,6 +407,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(), @@ -418,6 +421,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. diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 284d0fa1ff..9de844c6cd 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; 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..45031490b0 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 { 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 015ae5daf6..132a03afde 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -317,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 { @@ -335,6 +336,7 @@ impl ThreadRequestProcessor { thread_list_state_permit: Arc, thread_goal_processor: ThreadGoalRequestProcessor, state_db: Option, + skills_watcher: Arc, ) -> Self { Self { auth_manager, @@ -351,6 +353,7 @@ impl ThreadRequestProcessor { thread_goal_processor, state_db, background_tasks: TaskTracker::new(), + skills_watcher, } } @@ -752,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), } } @@ -849,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(); @@ -1049,7 +1054,6 @@ impl ThreadRequestProcessor { .collect() }; let core_dynamic_tool_count = core_dynamic_tools.len(); - let NewThread { thread_id, thread, 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..33acf65335 --- /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::file_watcher::FileWatcher; +use codex_core::file_watcher::FileWatcherSubscriber; +use codex_core::file_watcher::Receiver; +use codex_core::file_watcher::ThrottledWatchReceiver; +use codex_core::file_watcher::WatchPath; +use codex_core::file_watcher::WatchRegistration; +use codex_core::skills::SkillsLoadInput; +use codex_core::skills::SkillsManager; +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 82871fca8b..f31257e8cf 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_core::file_watcher::WatchRegistration; use codex_protocol::ThreadId; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; @@ -77,6 +78,7 @@ pub(crate) struct ThreadState { listener_command_tx: Option>, current_turn_history: ThreadHistoryBuilder, listener_thread: Option>, + watch_registration: WatchRegistration, } impl ThreadState { @@ -91,6 +93,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 +102,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 +113,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) { 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 39dae06bd0..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,8 +4,10 @@ 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; @@ -573,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, @@ -630,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/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 2503764904..793162cb2d 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -83,7 +83,6 @@ 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), conversation_history: initial_history.unwrap_or(InitialHistory::New), session_source: SessionSource::SubAgent(subagent_source.clone()), thread_source: Some(ThreadSource::Subagent), diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 9fe235640a..a0dd95c37b 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -1,6 +1,5 @@ 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; @@ -31,6 +30,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 +101,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 +111,6 @@ impl CodexThread { session_configured: SessionConfiguredEvent, rollout_path: Option, session_source: SessionSource, - watch_registration: WatchRegistration, ) -> Self { Self { codex, @@ -120,7 +118,6 @@ impl CodexThread { session_configured, rollout_path, out_of_band_elicitation_count: Mutex::new(0), - _watch_registration: watch_registration, } } @@ -471,6 +468,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/lib.rs b/codex-rs/core/src/lib.rs index 57ac9b4a59..41205c14db 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -100,7 +100,6 @@ pub(crate) use skills::manager; pub(crate) use skills::maybe_emit_implicit_skill_invocation; pub(crate) use skills::resolve_skill_dependencies_for_turn; pub(crate) use skills::skills_load_input_from_config; -mod skills_watcher; mod stream_events_utils; pub mod test_support; mod unified_exec; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index c6acf16f67..7e4c8d7073 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -113,6 +113,7 @@ use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::TurnContextNetworkItem; +use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::W3cTraceContext; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; @@ -283,8 +284,6 @@ use crate::rollout::map_session_init_error; use crate::session_startup_prewarm::SessionStartupPrewarmHandle; use crate::shell; use crate::shell_snapshot::ShellSnapshot; -use crate::skills_watcher::SkillsWatcher; -use crate::skills_watcher::SkillsWatcherEvent; use crate::state::ActiveTurn; use crate::state::MailboxDeliveryPhase; use crate::state::PendingRequestPermissions; @@ -393,7 +392,6 @@ pub(crate) struct CodexSpawnArgs { pub(crate) skills_manager: Arc, pub(crate) plugins_manager: Arc, pub(crate) mcp_manager: Arc, - pub(crate) skills_watcher: Arc, pub(crate) conversation_history: InitialHistory, pub(crate) session_source: SessionSource, pub(crate) thread_source: Option, @@ -457,7 +455,6 @@ impl Codex { skills_manager, plugins_manager, mcp_manager, - skills_watcher, conversation_history, session_source, thread_source, @@ -653,7 +650,6 @@ impl Codex { skills_manager, plugins_manager, mcp_manager.clone(), - skills_watcher, agent_control, environment_manager, analytics_events_client, @@ -788,6 +784,11 @@ impl Codex { state.session_configuration.thread_config_snapshot() } + pub(crate) async fn thread_environment_selections(&self) -> Vec { + let state = self.session.state.lock().await; + state.session_configuration.environments.clone() + } + pub(crate) fn state_db(&self) -> Option { self.session.state_db() } @@ -1021,29 +1022,6 @@ impl Session { self.out_of_band_elicitation_paused.send_replace(paused); } - fn start_skills_watcher_listener(self: &Arc) { - let mut rx = self.services.skills_watcher.subscribe(); - let weak_sess = Arc::downgrade(self); - tokio::spawn(async move { - loop { - match rx.recv().await { - Ok(SkillsWatcherEvent::SkillsChanged { .. }) => { - let Some(sess) = weak_sess.upgrade() else { - break; - }; - let event = Event { - id: sess.next_internal_sub_id(), - msg: EventMsg::SkillsUpdateAvailable, - }; - sess.send_event_raw(event).await; - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - } - } - }); - } - pub(crate) fn get_tx_event(&self) -> Sender { self.tx_event.clone() } diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 1a790314d5..23d22f94a5 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -364,7 +364,6 @@ impl Session { skills_manager: Arc, plugins_manager: Arc, mcp_manager: Arc, - skills_watcher: Arc, agent_control: AgentControl, environment_manager: Arc, analytics_events_client: Option, @@ -846,7 +845,6 @@ impl Session { skills_manager, plugins_manager: Arc::clone(&plugins_manager), mcp_manager: Arc::clone(&mcp_manager), - skills_watcher, agent_control, network_proxy, network_approval: Arc::clone(&network_approval), @@ -935,8 +933,6 @@ impl Session { sess.send_event_raw(event).await; } - // Start the watcher after SessionConfigured so it cannot emit earlier events. - sess.start_skills_watcher_listener(); let mut required_mcp_servers: Vec = mcp_servers .iter() .filter(|(_, server)| server.enabled() && server.required()) diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 5090fc3f07..0cb433c3e5 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3725,7 +3725,6 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { skills_manager, plugins_manager, mcp_manager, - Arc::new(SkillsWatcher::noop()), AgentControl::default(), Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, @@ -3837,7 +3836,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { .expect("create environment"), ); - let skills_watcher = Arc::new(SkillsWatcher::noop()); let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized( &config.permissions.approval_policy, @@ -3873,7 +3871,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { skills_manager, plugins_manager, mcp_manager, - skills_watcher, agent_control, network_proxy: None, network_approval: Arc::clone(&network_approval), @@ -4064,7 +4061,6 @@ async fn make_session_with_config_and_rx( skills_manager, plugins_manager, mcp_manager, - Arc::new(SkillsWatcher::noop()), AgentControl::default(), Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, @@ -4167,7 +4163,6 @@ async fn make_session_with_history_source_and_agent_control_and_rx( skills_manager, plugins_manager, mcp_manager, - Arc::new(SkillsWatcher::noop()), agent_control, Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), /*analytics_events_client*/ None, @@ -5556,7 +5551,6 @@ where .expect("create environment"), ); - let skills_watcher = Arc::new(SkillsWatcher::noop()); let services = SessionServices { mcp_connection_manager: Arc::new(RwLock::new(McpConnectionManager::new_uninitialized( &config.permissions.approval_policy, @@ -5592,7 +5586,6 @@ where skills_manager, plugins_manager, mcp_manager, - skills_watcher, agent_control, network_proxy: None, network_approval: Arc::clone(&network_approval), diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 37a5e6bb09..8d4bfd9cbe 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -728,7 +728,6 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { /*bundled_skills_enabled*/ true, )); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_watcher = Arc::new(SkillsWatcher::noop()); let thread_store = Arc::new(codex_thread_store::LocalThreadStore::new( codex_thread_store::LocalThreadStoreConfig::from_config(&config), /*state_db*/ None, @@ -743,7 +742,6 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { skills_manager, plugins_manager, mcp_manager, - skills_watcher, conversation_history: InitialHistory::New, session_source: SessionSource::SubAgent(SubAgentSource::Other( GUARDIAN_REVIEWER_NAME.to_string(), diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 1723904cff..bf552a79fd 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1505,7 +1505,6 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::StreamError(_) | EventMsg::TurnDiff(_) | EventMsg::RealtimeConversationListVoicesResponse(_) - | EventMsg::SkillsUpdateAvailable | EventMsg::PlanUpdate(_) | EventMsg::TurnAborted(_) | EventMsg::ShutdownComplete diff --git a/codex-rs/core/src/skills_watcher.rs b/codex-rs/core/src/skills_watcher.rs deleted file mode 100644 index fb271ca876..0000000000 --- a/codex-rs/core/src/skills_watcher.rs +++ /dev/null @@ -1,125 +0,0 @@ -//! Skills-specific watcher built on top of the generic [`FileWatcher`]. - -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; - -use tokio::runtime::Handle; -use tokio::sync::broadcast; -use tracing::warn; - -use crate::SkillsManager; -use crate::config::Config; -use crate::file_watcher::FileWatcher; -use crate::file_watcher::FileWatcherSubscriber; -use crate::file_watcher::Receiver; -use crate::file_watcher::ThrottledWatchReceiver; -use crate::file_watcher::WatchPath; -use crate::file_watcher::WatchRegistration; -use crate::skills_load_input_from_config; -use codex_core_plugins::PluginsManager; - -#[cfg(not(test))] -const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_secs(10); -#[cfg(test)] -const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_millis(50); - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum SkillsWatcherEvent { - SkillsChanged { paths: Vec }, -} - -pub(crate) struct SkillsWatcher { - subscriber: FileWatcherSubscriber, - tx: broadcast::Sender, -} - -impl SkillsWatcher { - pub(crate) fn new(file_watcher: &Arc) -> Self { - let (subscriber, rx) = file_watcher.add_subscriber(); - let (tx, _) = broadcast::channel(128); - let skills_watcher = Self { - subscriber, - tx: tx.clone(), - }; - Self::spawn_event_loop(rx, tx); - skills_watcher - } - - pub(crate) fn noop() -> Self { - Self::new(&Arc::new(FileWatcher::noop())) - } - - pub(crate) fn subscribe(&self) -> broadcast::Receiver { - self.tx.subscribe() - } - - pub(crate) async fn register_config( - &self, - config: &Config, - skills_manager: &SkillsManager, - plugins_manager: &PluginsManager, - fs: Option>, - ) -> WatchRegistration { - let plugins_input = config.plugins_config_input(); - let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await; - let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); - let skills_input = skills_load_input_from_config(config, effective_skill_roots); - let roots = skills_manager - .skill_roots_for_config(&skills_input, fs) - .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, tx: broadcast::Sender) { - let mut rx = ThrottledWatchReceiver::new(rx, WATCHER_THROTTLE_INTERVAL); - if let Ok(handle) = Handle::try_current() { - handle.spawn(async move { - while let Some(event) = rx.recv().await { - let _ = tx.send(SkillsWatcherEvent::SkillsChanged { paths: event.paths }); - } - }); - } else { - warn!("skills watcher listener skipped: no Tokio runtime available"); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use tokio::time::Duration; - use tokio::time::timeout; - - #[tokio::test] - async fn forwards_file_watcher_events() { - let file_watcher = Arc::new(FileWatcher::noop()); - let skills_watcher = SkillsWatcher::new(&file_watcher); - let mut rx = skills_watcher.subscribe(); - let _registration = skills_watcher - .subscriber - .register_path(PathBuf::from("/tmp/skill"), /*recursive*/ true); - - file_watcher - .send_paths_for_test(vec![PathBuf::from("/tmp/skill/SKILL.md")]) - .await; - - let event = timeout(Duration::from_secs(2), rx.recv()) - .await - .expect("skills watcher event") - .expect("broadcast recv"); - assert_eq!( - event, - SkillsWatcherEvent::SkillsChanged { - paths: vec![PathBuf::from("/tmp/skill/SKILL.md")], - } - ); - } -} diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 0dba931296..7e13eddb76 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -10,7 +10,6 @@ use crate::exec_policy::ExecPolicyManager; use crate::guardian::GuardianRejection; use crate::guardian::GuardianRejectionCircuitBreaker; use crate::mcp::McpManager; -use crate::skills_watcher::SkillsWatcher; use crate::tools::code_mode::CodeModeService; use crate::tools::network_approval::NetworkApprovalService; use crate::tools::sandboxing::ApprovalStore; @@ -60,7 +59,6 @@ pub(crate) struct SessionServices { pub(crate) skills_manager: Arc, pub(crate) plugins_manager: Arc, pub(crate) mcp_manager: Arc, - pub(crate) skills_watcher: Arc, pub(crate) agent_control: AgentControl, pub(crate) network_proxy: Option, pub(crate) network_approval: Arc, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index de4114189b..bfd91f68f8 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -6,7 +6,6 @@ use crate::config::Config; use crate::config::ThreadStoreConfig; use crate::environment_selection::default_thread_environment_selections; use crate::environment_selection::resolve_environment_selections; -use crate::file_watcher::FileWatcher; use crate::mcp::McpManager; use crate::rollout::truncation; use crate::session::Codex; @@ -14,8 +13,6 @@ use crate::session::CodexSpawnArgs; use crate::session::CodexSpawnOk; use crate::session::INITIAL_SUBMIT_ID; use crate::shell_snapshot::ShellSnapshot; -use crate::skills_watcher::SkillsWatcher; -use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::InterruptedTurnHistoryMarker; use crate::tasks::interrupted_turn_history_marker; use codex_analytics::AnalyticsEventsClient; @@ -71,8 +68,6 @@ use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use std::time::Duration; -use tokio::runtime::Handle; -use tokio::runtime::RuntimeFlavor; use tokio::sync::RwLock; use tokio::sync::broadcast; use tracing::warn; @@ -106,47 +101,6 @@ impl Drop for TempCodexHomeGuard { } } -fn build_skills_watcher(skills_manager: Arc) -> Arc { - if should_use_test_thread_manager_behavior() - && let Ok(handle) = Handle::try_current() - && handle.runtime_flavor() == RuntimeFlavor::CurrentThread - { - // The real watcher spins background tasks that can starve the - // current-thread test runtime and cause event waits to time out. - warn!("using noop skills watcher under current-thread test runtime"); - return Arc::new(SkillsWatcher::noop()); - } - - let file_watcher = match FileWatcher::new() { - Ok(file_watcher) => Arc::new(file_watcher), - Err(err) => { - warn!("failed to initialize file watcher: {err}"); - Arc::new(FileWatcher::noop()) - } - }; - let skills_watcher = Arc::new(SkillsWatcher::new(&file_watcher)); - - let mut rx = skills_watcher.subscribe(); - let skills_manager = Arc::clone(&skills_manager); - if let Ok(handle) = Handle::try_current() { - handle.spawn(async move { - loop { - match rx.recv().await { - Ok(SkillsWatcherEvent::SkillsChanged { .. }) => { - skills_manager.clear_cache(); - } - Err(broadcast::error::RecvError::Closed) => break, - Err(broadcast::error::RecvError::Lagged(_)) => continue, - } - } - }); - } else { - warn!("skills watcher listener skipped: no Tokio runtime available"); - } - - skills_watcher -} - /// Represents a newly created Codex thread (formerly called a conversation), including the first event /// (which is [`EventMsg::SessionConfigured`]). pub struct NewThread { @@ -247,7 +201,6 @@ pub(crate) struct ThreadManagerState { skills_manager: Arc, plugins_manager: Arc, mcp_manager: Arc, - skills_watcher: Arc, thread_store: Arc, attestation_provider: Option>, session_source: SessionSource, @@ -308,7 +261,6 @@ impl ThreadManager { config.bundled_skills_enabled(), restriction_product, )); - let skills_watcher = build_skills_watcher(Arc::clone(&skills_manager)); Self { state: Arc::new(ThreadManagerState { threads: Arc::new(RwLock::new(HashMap::new())), @@ -318,7 +270,6 @@ impl ThreadManager { skills_manager, plugins_manager, mcp_manager, - skills_watcher, thread_store, attestation_provider, auth_manager, @@ -399,7 +350,6 @@ impl ThreadManager { /*bundled_skills_enabled*/ true, restriction_product, )); - let skills_watcher = build_skills_watcher(Arc::clone(&skills_manager)); // This test constructor has no Config input. Tests that need a non-local // process store should construct ThreadManager::new with an explicit store. let thread_store: Arc = Arc::new(LocalThreadStore::new( @@ -420,7 +370,6 @@ impl ThreadManager { skills_manager, plugins_manager, mcp_manager, - skills_watcher, thread_store, attestation_provider: None, auth_manager, @@ -1165,19 +1114,6 @@ impl ThreadManagerState { } let environment_selections = resolve_environment_selections(self.environment_manager.as_ref(), &environments)?; - let watch_registration = match environment_selections.primary() { - Some(turn_environment) if !turn_environment.environment.is_remote() => { - self.skills_watcher - .register_config( - &config, - self.skills_manager.as_ref(), - self.plugins_manager.as_ref(), - Some(turn_environment.environment.get_filesystem()), - ) - .await - } - Some(_) | None => crate::file_watcher::WatchRegistration::default(), - }; let parent_rollout_thread_trace = self .parent_rollout_thread_trace_for_source(&session_source, &initial_history) .await; @@ -1193,7 +1129,6 @@ impl ThreadManagerState { skills_manager: Arc::clone(&self.skills_manager), plugins_manager: Arc::clone(&self.plugins_manager), mcp_manager: Arc::clone(&self.mcp_manager), - skills_watcher: Arc::clone(&self.skills_watcher), conversation_history: initial_history, session_source, thread_source, @@ -1213,7 +1148,7 @@ impl ThreadManagerState { }) .await?; let new_thread = self - .finalize_thread_spawn(codex, thread_id, tracked_session_source, watch_registration) + .finalize_thread_spawn(codex, thread_id, tracked_session_source) .await?; if is_resumed_thread && let Err(err) = new_thread.thread.apply_goal_resume_runtime_effects().await @@ -1228,7 +1163,6 @@ impl ThreadManagerState { codex: Codex, thread_id: ThreadId, session_source: SessionSource, - watch_registration: crate::file_watcher::WatchRegistration, ) -> CodexResult { let event = codex.next_event().await?; let session_configured = match event { @@ -1249,7 +1183,6 @@ impl ThreadManagerState { session_configured.clone(), session_configured.rollout_path.clone(), session_source, - watch_registration, )); e.insert(thread.clone()); return Ok(NewThread { diff --git a/codex-rs/core/tests/suite/live_reload.rs b/codex-rs/core/tests/suite/live_reload.rs deleted file mode 100644 index c422073e4f..0000000000 --- a/codex-rs/core/tests/suite/live_reload.rs +++ /dev/null @@ -1,157 +0,0 @@ -#![allow(clippy::expect_used, clippy::unwrap_used)] - -use std::fs; -use std::path::Path; -use std::path::PathBuf; -use std::time::Duration; - -use anyhow::Result; -use codex_config::config_toml::ProjectConfig; -use codex_protocol::config_types::TrustLevel; -use codex_protocol::models::PermissionProfile; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::Op; -use codex_protocol::user_input::UserInput; -use core_test_support::responses; -use core_test_support::responses::ResponsesRequest; -use core_test_support::responses::mount_sse_sequence; -use core_test_support::responses::start_mock_server; -use core_test_support::test_codex::TestCodex; -use core_test_support::test_codex::test_codex; -use core_test_support::test_codex::turn_permission_fields; -use core_test_support::wait_for_event; -use tokio::time::timeout; - -fn enable_trusted_project(config: &mut codex_core::config::Config) { - config.active_project = ProjectConfig { - trust_level: Some(TrustLevel::Trusted), - }; -} - -fn write_skill(home: &Path, name: &str, description: &str, body: &str) -> PathBuf { - let skill_dir = home.join("skills").join(name); - fs::create_dir_all(&skill_dir).expect("create skill dir"); - let contents = format!("---\nname: {name}\ndescription: {description}\n---\n\n{body}\n"); - let path = skill_dir.join("SKILL.md"); - fs::write(&path, contents).expect("write skill"); - path -} - -fn contains_skill_body(request: &ResponsesRequest, skill_body: &str) -> bool { - request - .message_input_texts("user") - .iter() - .any(|text| text.contains(skill_body) && text.contains("")) -} - -async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) -> Result<()> { - let session_model = test.session_configured.model.clone(); - let (sandbox_policy, permission_profile) = - turn_permission_fields(PermissionProfile::Disabled, test.cwd_path()); - test.codex - .submit(Op::UserTurn { - environments: None, - items: vec![ - UserInput::Text { - text: prompt.to_string(), - text_elements: Vec::new(), - }, - UserInput::Skill { - name: "demo".to_string(), - path: skill_path, - }, - ], - final_output_json_schema: None, - cwd: test.cwd_path().to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy, - permission_profile, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; - - wait_for_event(test.codex.as_ref(), |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn live_skills_reload_refreshes_skill_cache_after_skill_change() -> Result<()> { - let server = start_mock_server().await; - let responses = mount_sse_sequence( - &server, - vec![ - responses::sse(vec![responses::ev_completed("resp-1")]), - responses::sse(vec![responses::ev_completed("resp-2")]), - ], - ) - .await; - - let skill_v1 = "skill body v1"; - let skill_v2 = "skill body v2"; - let mut builder = test_codex() - .with_pre_build_hook(move |home| { - write_skill(home, "demo", "demo skill", skill_v1); - }) - .with_config(|config| { - enable_trusted_project(config); - }); - let test = builder.build(&server).await?; - - let skill_path = dunce::canonicalize(test.codex_home_path().join("skills/demo/SKILL.md"))?; - - submit_skill_turn(&test, skill_path.clone(), "please use $demo").await?; - let first_request = responses - .requests() - .first() - .cloned() - .expect("first request captured"); - assert!( - contains_skill_body(&first_request, skill_v1), - "expected initial skill body in request" - ); - - write_skill(test.codex_home_path(), "demo", "demo skill", skill_v2); - - let saw_skills_update = timeout(Duration::from_secs(5), async { - loop { - match test.codex.next_event().await { - Ok(event) => { - if matches!(event.msg, EventMsg::SkillsUpdateAvailable) { - break; - } - } - Err(err) => panic!("event stream ended unexpectedly: {err}"), - } - } - }) - .await; - - if saw_skills_update.is_err() { - // Some environments do not reliably surface file watcher events for - // skill changes. Clear the cache explicitly so we can still validate - // that the updated skill body is injected on the next turn. - test.thread_manager.skills_manager().clear_cache(); - } - - submit_skill_turn(&test, skill_path.clone(), "please use $demo again").await?; - let last_request = responses - .last_request() - .expect("request captured after skill update"); - - assert!( - contains_skill_body(&last_request, skill_v2), - "expected updated skill body after reload" - ); - - Ok(()) -} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 0c91654c83..843b558ebf 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -57,7 +57,6 @@ mod image_rollout; mod items; mod json_result; mod live_cli; -mod live_reload; mod model_overrides; mod model_switching; mod model_visible_layout; diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index ff273a77c4..31d30d824e 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -754,6 +754,9 @@ async fn conversation_webrtc_sideband_connect_failure_closes_with_error() -> Res config.experimental_realtime_ws_model = Some("realtime-test-model".to_string()); config.experimental_realtime_ws_startup_context = Some(String::new()); config.experimental_realtime_ws_base_url = Some("http://127.0.0.1:1".to_string()); + // Keep the failure-path test inside wait_for_event's timeout on Windows, + // where refused localhost websocket connects can take around two seconds. + config.model_provider.request_max_retries = Some(0); config.realtime.version = RealtimeWsVersion::V1; }); let test = builder.build(&server).await?; diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 4e2d5c08e5..9e3ac1bae9 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -364,7 +364,6 @@ async fn run_codex_tool_session_inner( | EventMsg::AgentMessageContentDelta(_) | EventMsg::ReasoningContentDelta(_) | EventMsg::ReasoningRawContentDelta(_) - | EventMsg::SkillsUpdateAvailable | EventMsg::ExitedReviewMode(_) | EventMsg::RequestUserInput(_) | EventMsg::RequestPermissions(_) diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 30e33abe43..91fd02d858 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1401,9 +1401,6 @@ pub enum EventMsg { /// List of voices supported by realtime conversation streams. RealtimeConversationListVoicesResponse(RealtimeConversationListVoicesResponseEvent), - /// Notification that skill data may have been updated and clients may want to reload. - SkillsUpdateAvailable, - PlanUpdate(UpdatePlanArgs), TurnAborted(TurnAbortedEvent), diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index 3d52798b8d..1e49c82be2 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -260,7 +260,6 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Option<&'static s | EventMsg::PatchApplyEnd(_) | EventMsg::TurnDiff(_) | EventMsg::RealtimeConversationListVoicesResponse(_) - | EventMsg::SkillsUpdateAvailable | EventMsg::PlanUpdate(_) | EventMsg::EnteredReviewMode(_) | EventMsg::ExitedReviewMode(_) diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 558c3fef98..21b98b4e8d 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -169,7 +169,6 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::ReasoningContentDelta(_) | EventMsg::ReasoningRawContentDelta(_) | EventMsg::ImageGenerationBegin(_) - | EventMsg::SkillsUpdateAvailable | EventMsg::CollabAgentSpawnBegin(_) | EventMsg::CollabAgentInteractionBegin(_) | EventMsg::CollabWaitingBegin(_) From c579da41b16dc88b62d9cb2611f70ccdb7ac2735 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Fri, 8 May 2026 18:19:23 -0700 Subject: [PATCH 25/27] Move file watcher out of core (#21290) ## Why The app-server watcher relocation leaves the generic filesystem watcher as the last watcher-specific implementation still living inside `codex-core`. Moving that code to a small crate keeps `codex-core` focused on thread execution and lets app-server depend on the watcher without reaching back into core for filesystem watching primitives. This PR is stacked on #21287. ## What changed - Added a new `codex-file-watcher` crate containing the existing watcher implementation and its unit tests. - Updated app-server `fs_watch`, `skills_watcher`, and listener state to import watcher types from `codex-file-watcher`. - Removed the `file_watcher` module and `notify` dependency from `codex-core`. - Updated Cargo workspace metadata and `Cargo.lock` for the new internal crate. ## Validation - `cargo check -p codex-file-watcher -p codex-core -p codex-app-server` - `cargo test -p codex-file-watcher` - `cargo test -p codex-app-server skills_changed_notification_is_emitted_after_skill_change` - `just bazel-lock-update` - `just bazel-lock-check` - `just fix -p codex-file-watcher` - `just fix -p codex-core` - `just fix -p codex-app-server` --- codex-rs/Cargo.lock | 13 ++++++++++- codex-rs/Cargo.toml | 2 ++ codex-rs/app-server/Cargo.toml | 1 + codex-rs/app-server/src/fs_watch.rs | 12 +++++----- codex-rs/app-server/src/skills_watcher.rs | 12 +++++----- codex-rs/app-server/src/thread_state.rs | 2 +- codex-rs/core/Cargo.toml | 1 - codex-rs/core/src/lib.rs | 2 -- codex-rs/file-watcher/BUILD.bazel | 6 +++++ codex-rs/file-watcher/Cargo.toml | 22 +++++++++++++++++++ .../src/file_watcher_tests.rs | 0 .../src/lib.rs} | 0 12 files changed, 56 insertions(+), 17 deletions(-) create mode 100644 codex-rs/file-watcher/BUILD.bazel create mode 100644 codex-rs/file-watcher/Cargo.toml rename codex-rs/{core => file-watcher}/src/file_watcher_tests.rs (100%) rename codex-rs/{core/src/file_watcher.rs => file-watcher/src/lib.rs} (100%) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 8ea52e9304..ec654051e4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1900,6 +1900,7 @@ dependencies = [ "codex-features", "codex-feedback", "codex-file-search", + "codex-file-watcher", "codex-git-utils", "codex-hooks", "codex-login", @@ -2551,7 +2552,6 @@ dependencies = [ "insta", "libc", "maplit", - "notify", "once_cell", "openssl-sys", "opentelemetry", @@ -2894,6 +2894,17 @@ dependencies = [ "serde", ] +[[package]] +name = "codex-file-watcher" +version = "0.0.0" +dependencies = [ + "notify", + "pretty_assertions", + "tempfile", + "tokio", + "tracing", +] + [[package]] name = "codex-git-utils" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 9e203e9f8a..dd0cd7e491 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -49,6 +49,7 @@ members = [ "external-agent-sessions", "keyring-store", "file-search", + "file-watcher", "linux-sandbox", "lmstudio", "login", @@ -166,6 +167,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" } diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index b4a5e64a89..5675640ff0 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -41,6 +41,7 @@ codex-external-agent-migration = { workspace = true } codex-external-agent-sessions = { workspace = true } codex-features = { workspace = true } codex-git-utils = { workspace = true } +codex-file-watcher = { workspace = true } codex-hooks = { workspace = true } codex-otel = { workspace = true } codex-plugin = { workspace = true } 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/skills_watcher.rs b/codex-rs/app-server/src/skills_watcher.rs index 33acf65335..d20628d386 100644 --- a/codex-rs/app-server/src/skills_watcher.rs +++ b/codex-rs/app-server/src/skills_watcher.rs @@ -6,14 +6,14 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SkillsChangedNotification; use codex_core::ThreadManager; use codex_core::config::Config; -use codex_core::file_watcher::FileWatcher; -use codex_core::file_watcher::FileWatcherSubscriber; -use codex_core::file_watcher::Receiver; -use codex_core::file_watcher::ThrottledWatchReceiver; -use codex_core::file_watcher::WatchPath; -use codex_core::file_watcher::WatchRegistration; 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; diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index f31257e8cf..f0dbb0e326 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -7,7 +7,7 @@ use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError; use codex_core::CodexThread; use codex_core::ThreadConfigSnapshot; -use codex_core::file_watcher::WatchRegistration; +use codex_file_watcher::WatchRegistration; use codex_protocol::ThreadId; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 5e799b259f..3e1d650a98 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -84,7 +84,6 @@ iana-time-zone = { workspace = true } image = { workspace = true, features = ["jpeg", "png", "webp"] } indexmap = { workspace = true } libc = { workspace = true } -notify = { workspace = true } once_cell = { workspace = true } rand = { workspace = true } regex-lite = { workspace = true } diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 41205c14db..7b3de524c5 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -35,7 +35,6 @@ mod environment_selection; pub mod exec; pub mod exec_env; mod exec_policy; -pub mod file_watcher; mod flags; #[cfg(test)] mod git_info_tests; @@ -194,7 +193,6 @@ pub use exec_policy::ExecPolicyError; pub use exec_policy::check_execpolicy_for_warnings; pub use exec_policy::format_exec_policy_error_with_source; pub use exec_policy::load_exec_policy; -pub use file_watcher::FileWatcherEvent; pub use installation_id::resolve_installation_id; pub use turn_metadata::build_turn_metadata_header; pub mod compact; diff --git a/codex-rs/file-watcher/BUILD.bazel b/codex-rs/file-watcher/BUILD.bazel new file mode 100644 index 0000000000..59cf10350c --- /dev/null +++ b/codex-rs/file-watcher/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "file-watcher", + crate_name = "codex_file_watcher", +) diff --git a/codex-rs/file-watcher/Cargo.toml b/codex-rs/file-watcher/Cargo.toml new file mode 100644 index 0000000000..7816987947 --- /dev/null +++ b/codex-rs/file-watcher/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "codex-file-watcher" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_file_watcher" +path = "src/lib.rs" +doctest = false + +[lints] +workspace = true + +[dependencies] +notify = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] } +tracing = { workspace = true, features = ["log"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/core/src/file_watcher_tests.rs b/codex-rs/file-watcher/src/file_watcher_tests.rs similarity index 100% rename from codex-rs/core/src/file_watcher_tests.rs rename to codex-rs/file-watcher/src/file_watcher_tests.rs diff --git a/codex-rs/core/src/file_watcher.rs b/codex-rs/file-watcher/src/lib.rs similarity index 100% rename from codex-rs/core/src/file_watcher.rs rename to codex-rs/file-watcher/src/lib.rs From 479491ed892568f70fe9489ec23e97cdd107f501 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Fri, 8 May 2026 20:46:39 -0700 Subject: [PATCH 26/27] feat: Add role-aware plugin share context APIs (#21867) Expose discoverability and full share principals in share context, carry roles through save/updateTargets, hydrate local shared plugin reads, and keep share URLs only under plugin.shareContext. --- .../schema/json/ClientRequest.json | 13 +- .../codex_app_server_protocol.schemas.json | 45 +++- .../codex_app_server_protocol.v2.schemas.json | 45 +++- .../schema/json/v2/PluginListResponse.json | 34 ++- .../schema/json/v2/PluginReadResponse.json | 34 ++- .../json/v2/PluginShareListResponse.json | 40 +++- .../schema/json/v2/PluginShareSaveParams.json | 13 +- .../v2/PluginShareUpdateTargetsParams.json | 13 +- .../v2/PluginShareUpdateTargetsResponse.json | 14 +- .../typescript/v2/PluginShareContext.ts | 3 +- .../typescript/v2/PluginShareListItem.ts | 2 +- .../typescript/v2/PluginSharePrincipal.ts | 3 +- .../typescript/v2/PluginSharePrincipalRole.ts | 5 + .../schema/typescript/v2/PluginShareTarget.ts | 3 +- .../typescript/v2/PluginShareTargetRole.ts | 5 + .../schema/typescript/v2/index.ts | 2 + .../src/protocol/v2/plugin.rs | 25 ++- .../src/protocol/v2/tests.rs | 18 +- .../src/request_processors/plugins.rs | 125 +++++++++-- .../app-server/tests/suite/v2/plugin_list.rs | 35 +-- .../app-server/tests/suite/v2/plugin_read.rs | 207 +++++++++++++++++- .../app-server/tests/suite/v2/plugin_share.rs | 74 ++++++- codex-rs/core-plugins/src/remote.rs | 100 +++++---- codex-rs/core-plugins/src/remote/share.rs | 75 +++++-- .../core-plugins/src/remote/share/tests.rs | 114 ++++------ 25 files changed, 826 insertions(+), 221 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginSharePrincipalRole.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginShareTargetRole.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index cb14a3a91a..6351993046 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2088,14 +2088,25 @@ }, "principalType": { "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginShareTargetRole" } }, "required": [ "principalId", - "principalType" + "principalType", + "role" ], "type": "object" }, + "PluginShareTargetRole": { + "enum": [ + "reader", + "editor" + ], + "type": "string" + }, "PluginShareUpdateDiscoverability": { "enum": [ "UNLISTED", 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 0130e97653..215842d929 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 @@ -12270,10 +12270,20 @@ "null" ] }, + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "remotePluginId": { "type": "string" }, - "shareTargets": { + "sharePrincipals": { "items": { "$ref": "#/definitions/v2/PluginSharePrincipal" }, @@ -12334,14 +12344,10 @@ }, "plugin": { "$ref": "#/definitions/v2/PluginSummary" - }, - "shareUrl": { - "type": "string" } }, "required": [ - "plugin", - "shareUrl" + "plugin" ], "type": "object" }, @@ -12376,15 +12382,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", @@ -12455,14 +12473,25 @@ }, "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", 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 66180b11e1..085744e819 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 @@ -8819,10 +8819,20 @@ "null" ] }, + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "remotePluginId": { "type": "string" }, - "shareTargets": { + "sharePrincipals": { "items": { "$ref": "#/definitions/PluginSharePrincipal" }, @@ -8883,14 +8893,10 @@ }, "plugin": { "$ref": "#/definitions/PluginSummary" - }, - "shareUrl": { - "type": "string" } }, "required": [ - "plugin", - "shareUrl" + "plugin" ], "type": "object" }, @@ -8925,15 +8931,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", @@ -9004,14 +9022,25 @@ }, "principalType": { "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginShareTargetRole" } }, "required": [ "principalId", - "principalType" + "principalType", + "role" ], "type": "object" }, + "PluginShareTargetRole": { + "enum": [ + "reader", + "editor" + ], + "type": "string" + }, "PluginShareUpdateDiscoverability": { "enum": [ "UNLISTED", 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..473d1a2a5d 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", 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..d4473d96ac 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", 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..ae70ed5cdd 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", 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 f6b44c92eb..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,14 +16,25 @@ }, "principalType": { "$ref": "#/definitions/PluginSharePrincipalType" + }, + "role": { + "$ref": "#/definitions/PluginShareTargetRole" } }, "required": [ "principalId", - "principalType" + "principalType", + "role" ], "type": "object" }, + "PluginShareTargetRole": { + "enum": [ + "reader", + "editor" + ], + "type": "string" + }, "PluginShareUpdateDiscoverability": { "enum": [ "UNLISTED", 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 fe47f1f4af..4923be498a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareUpdateTargetsResponse.json @@ -19,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", 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/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 7c431f9ec3..a6b961366e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -285,10 +285,12 @@ 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"; 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 6f425b4a6a..ed03cc6ff3 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -259,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, } @@ -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")] @@ -539,10 +559,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/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index da0ad2c10e..30599776ae 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2896,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, }, ]), }) @@ -2912,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", }, ], }), @@ -2940,6 +2944,7 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() { share_targets: vec![PluginShareTarget { principal_type: PluginSharePrincipalType::Group, principal_id: "group-1".to_string(), + role: PluginShareTargetRole::Editor, }], }) .unwrap(), @@ -2949,6 +2954,7 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() { "shareTargets": [{ "principalType": "group", "principalId": "group-1", + "role": "editor", }], }), ); @@ -2958,6 +2964,7 @@ 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, @@ -2967,6 +2974,7 @@ fn plugin_share_params_and_response_serialization_use_camel_case_fields() { "principals": [{ "principalType": "user", "principalId": "user-1", + "role": "owner", "name": "Gavin", }], "discoverability": "UNLISTED", @@ -3007,7 +3015,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, }], }) @@ -3027,7 +3034,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/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 65bb390851..23d76d55b7 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, } @@ -160,6 +164,35 @@ fn validate_client_plugin_share_targets( 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 { @@ -179,6 +212,7 @@ fn remote_plugin_share_targets( } }, principal_id: target.principal_id, + role: remote_plugin_share_target_role(target.role), }, ) .collect() @@ -200,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, } } @@ -415,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)?; @@ -526,10 +561,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, @@ -603,11 +653,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) @@ -807,7 +902,6 @@ impl PluginRequestProcessor { return Err(invalid_request("invalid remote plugin id")); } validate_client_plugin_share_targets(&share_targets)?; - let requested_share_targets = share_targets.clone(); let remote_plugin_service_config = RemotePluginServiceConfig { chatgpt_base_url: config.chatgpt_base_url.clone(), @@ -829,12 +923,6 @@ impl PluginRequestProcessor { .principals .into_iter() .map(plugin_share_principal_from_remote) - .filter(|principal| { - requested_share_targets.iter().any(|target| { - target.principal_type == principal.principal_type - && target.principal_id == principal.principal_id - }) - }) .collect(), discoverability: remote_plugin_share_discoverability_to_info(result.discoverability), }) @@ -859,13 +947,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, } }) @@ -1521,11 +1607,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() 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..a0205cb059 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; @@ -694,10 +693,11 @@ async fn plugin_list_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(()) } @@ -1680,12 +1680,15 @@ 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", + /*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", @@ -1734,6 +1737,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 +1750,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(()) } @@ -2275,6 +2275,7 @@ fn workspace_remote_plugin_page_body( "id": "{remote_plugin_id}", "name": "{plugin_name}", "scope": "WORKSPACE", + "discoverability": "PRIVATE", "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..769749b34b 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; @@ -237,6 +240,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", @@ -319,6 +323,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 +337,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(()) } @@ -751,6 +768,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 +794,120 @@ 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)?; + + 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)?; @@ -795,10 +938,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 dc1f56d487..b081017ad4 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; @@ -172,7 +174,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), }], } @@ -224,10 +225,12 @@ async fn plugin_share_save_forwards_access_policy() -> Result<()> { { "principal_type": "user", "principal_id": "user-1", + "role": "editor", }, { "principal_type": "workspace", "principal_id": "account-123", + "role": "reader", }, ], }))) @@ -252,6 +255,7 @@ async fn plugin_share_save_forwards_access_policy() -> Result<()> { { "principalType": "user", "principalId": "user-1", + "role": "editor", }, ], })), @@ -345,6 +349,7 @@ async fn plugin_share_rejects_workspace_targets_from_client() -> Result<()> { { "principalType": "workspace", "principalId": "account-123", + "role": "reader", }, ], })), @@ -373,6 +378,7 @@ async fn plugin_share_rejects_workspace_targets_from_client() -> Result<()> { { "principalType": "workspace", "principalId": "account-123", + "role": "reader", }, ], })), @@ -422,6 +428,7 @@ async fn plugin_share_save_rejects_access_policy_for_existing_plugin() -> Result { "principalType": "user", "principalId": "user-1", + "role": "reader", }, ], })), @@ -511,7 +518,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, }], } @@ -543,10 +549,12 @@ async fn plugin_share_update_targets_updates_share_targets() -> Result<()> { { "principal_type": "user", "principal_id": "user-1", + "role": "editor", }, { "principal_type": "workspace", "principal_id": "account-123", + "role": "reader", }, ], }))) @@ -555,19 +563,23 @@ async fn plugin_share_update_targets_updates_share_targets() -> Result<()> { { "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) @@ -585,6 +597,7 @@ async fn plugin_share_update_targets_updates_share_targets() -> Result<()> { { "principalType": "user", "principalId": "user-1", + "role": "editor", }, ], })), @@ -601,11 +614,26 @@ 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, } ); @@ -709,7 +737,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, }], } @@ -737,7 +764,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": { @@ -793,10 +835,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/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 4316976284..ed05053045 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -32,9 +32,11 @@ 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; @@ -99,16 +101,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, } @@ -361,6 +363,8 @@ struct RemotePluginDirectoryItem { name: String, scope: RemotePluginScope, #[serde(default)] + discoverability: Option, + #[serde(default)] creator_account_user_id: Option, #[serde(default)] creator_name: Option, @@ -379,8 +383,7 @@ struct RemotePluginDirectoryItem { struct RemotePluginDirectorySharePrincipal { principal_type: RemotePluginSharePrincipalType, principal_id: String, - #[serde(default)] - role: Option, + role: RemotePluginSharePrincipalRole, name: String, } @@ -444,7 +447,7 @@ pub async fn fetch_remote_marketplaces( directory_plugins, installed_plugins, /*include_installed_only*/ true, - ) + )? } RemoteMarketplaceSource::WorkspaceDirectory => { let scope = RemotePluginScope::Workspace; @@ -456,7 +459,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, @@ -464,7 +467,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); @@ -480,7 +483,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)) @@ -500,7 +503,7 @@ fn build_remote_marketplace( .cloned() .collect::>(); if plugin_ids.is_empty() { - return None; + return Ok(None); } let mut plugins = plugin_ids @@ -510,9 +513,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() @@ -520,11 +524,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( @@ -576,6 +580,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>, @@ -687,7 +704,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, @@ -823,11 +840,11 @@ fn remove_remote_plugin_cache( fn build_remote_plugin_summary( plugin: &RemotePluginDirectoryItem, installed_plugin: Option<&RemotePluginInstalledItem>, -) -> RemotePluginSummary { - RemotePluginSummary { +) -> Result { + Ok(RemotePluginSummary { 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, @@ -835,31 +852,40 @@ 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 = plugin.discoverability.ok_or_else(|| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "workspace plugin `{}` did not include discoverability", + plugin.id + )) + })?; + 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() + }), + })) + } } } diff --git a/codex-rs/core-plugins/src/remote/share.rs b/codex-rs/core-plugins/src/remote/share.rs index d69d22ea52..6afeab74b1 100644 --- a/codex-rs/core-plugins/src/remote/share.rs +++ b/codex-rs/core-plugins/src/remote/share.rs @@ -59,15 +59,32 @@ 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, @@ -115,7 +132,7 @@ struct RemotePluginShareUpdateTargetsRequest { #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RemotePluginShareUpdateTargetsResponse { principals: Vec, - discoverability: Option, + discoverability: RemotePluginShareDiscoverability, } pub async fn save_remote_plugin_share( @@ -203,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( @@ -284,9 +322,7 @@ pub async fn update_remote_plugin_share_targets( let response: RemotePluginShareUpdateTargetsResponse = send_and_decode(request, &url).await?; Ok(RemotePluginShareUpdateTargetsResult { principals: response.principals, - // TODO: Remove this fallback once deployed plugin-service responses always include - // discoverability per the API schema. - discoverability: response.discoverability.unwrap_or(target_discoverability), + discoverability: response.discoverability, }) } @@ -311,6 +347,7 @@ fn ensure_unlisted_workspace_target( targets.push(RemotePluginShareTarget { principal_type: RemotePluginSharePrincipalType::Workspace, principal_id: account_id, + role: RemotePluginShareTargetRole::Reader, }); } Ok(Some(targets)) diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index 35909a8b19..8791cde597 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -116,6 +116,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 @@ -209,10 +210,12 @@ async fn save_remote_plugin_share_creates_workspace_plugin() { { "principal_type": "user", "principal_id": "user-1", + "role": "reader", }, { "principal_type": "workspace", "principal_id": "account_id", + "role": "reader", }, ], }))) @@ -235,6 +238,7 @@ async fn save_remote_plugin_share_creates_workspace_plugin() { share_targets: Some(vec![RemotePluginShareTarget { principal_type: RemotePluginSharePrincipalType::User, principal_id: "user-1".to_string(), + role: RemotePluginShareTargetRole::Reader, }]), }, ) @@ -404,14 +408,17 @@ async fn update_remote_plugin_share_targets_updates_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", }, ], }))) @@ -420,11 +427,13 @@ 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", }, ], @@ -442,10 +451,12 @@ 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, @@ -460,11 +471,13 @@ 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(), }, ], @@ -473,64 +486,6 @@ async fn update_remote_plugin_share_targets_updates_targets() { ); } -#[tokio::test] -async fn update_remote_plugin_share_targets_falls_back_to_requested_discoverability() { - let server = MockServer::start().await; - let config = test_config(&server); - let auth = test_auth(); - - Mock::given(method("PUT")) - .and(path("/backend-api/ps/plugins/plugins_123/shares")) - .and(header("authorization", "Bearer Access Token")) - .and(header("chatgpt-account-id", "account_id")) - .and(body_json(json!({ - "discoverability": "PRIVATE", - "targets": [ - { - "principal_type": "user", - "principal_id": "user-1", - }, - ], - }))) - .respond_with(ResponseTemplate::new(200).set_body_json(json!({ - "principals": [ - { - "principal_type": "user", - "principal_id": "user-1", - "name": "Gavin", - }, - ], - }))) - .expect(1) - .mount(&server) - .await; - - let result = update_remote_plugin_share_targets( - &config, - Some(&auth), - "plugins_123", - vec![RemotePluginShareTarget { - principal_type: RemotePluginSharePrincipalType::User, - principal_id: "user-1".to_string(), - }], - RemotePluginShareUpdateDiscoverability::Private, - ) - .await - .unwrap(); - - assert_eq!( - result, - RemotePluginShareUpdateTargetsResult { - principals: vec![RemotePluginSharePrincipal { - principal_type: RemotePluginSharePrincipalType::User, - principal_id: "user-1".to_string(), - name: "Gavin".to_string(), - }], - discoverability: RemotePluginShareDiscoverability::Private, - } - ); -} - #[tokio::test] async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { let codex_home = TempDir::new().unwrap(); @@ -602,11 +557,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(), @@ -638,16 +588,26 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { 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, @@ -657,7 +617,6 @@ 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 { @@ -666,10 +625,24 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { 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, @@ -679,7 +652,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, } ] From 77d9223e9f0aec685ecd5eca3a25326a0ad196de Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Fri, 8 May 2026 20:52:48 -0700 Subject: [PATCH 27/27] [codex] compact network context rendering (#21875) ## Why The model-visible `` context currently repeats indentation and a pair of XML tags for every allowed or denied domain. Large domain sets spend a surprising amount of prompt budget on that scaffolding instead of the actual policy values. ## What changed - Render allowed domains as one comma-separated `` value instead of one element per domain. - Render denied domains the same way. - Keep the full allow/deny domain sets model-visible while updating the serialization and settings-update coverage for the denser shape. ## Example Before: ```xml api.example.test cdn.example.test blocked.example.test ``` After: ```xml api.example.test,cdn.example.testblocked.example.test ``` ## Validation - `cargo test -p codex-core environment_context` - `cargo test -p codex-core build_settings_update_items_emits_environment_item_for_network_changes` - Ran a local `codex` session with a real network context containing 121 allowed domains and 42 denied domains, then inspected the raw prompt with `raw_token_viewer_cli.py`. With the same domain set, the rendered `` section shrank from 7,175 characters across 161 lines to 3,666 characters on one line, and the containing environment-context block fell from 6,428 tokens to 5,379 tokens. --- .../core/src/context/environment_context.rs | 27 +++++++++++++------ .../src/context/environment_context_tests.rs | 6 +---- codex-rs/core/src/session/tests.rs | 6 ++--- 3 files changed, 23 insertions(+), 16 deletions(-) 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/session/tests.rs b/codex-rs/core/src/session/tests.rs index 0cb433c3e5..6a5b08c124 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5896,9 +5896,9 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes( .into_iter() .find(|text| text.contains("")) .expect("environment update item should be emitted"); - assert!(environment_update.contains("")); - assert!(environment_update.contains("api.example.com")); - assert!(environment_update.contains("blocked.example.com")); + assert!(environment_update.contains( + "api.example.comblocked.example.com" + )); } #[tokio::test]