diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 082434de0b..cc3968d306 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -57,6 +57,9 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Check rusty_v8 MODULE.bazel checksums if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu' @@ -149,6 +152,9 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Prepare Bazel CI id: prepare_bazel @@ -232,6 +238,9 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Prepare Bazel CI id: prepare_bazel @@ -319,6 +328,9 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Prepare Bazel CI id: prepare_bazel diff --git a/.github/workflows/blob-size-policy.yml b/.github/workflows/blob-size-policy.yml index e7cd67072f..779198ee02 100644 --- a/.github/workflows/blob-size-policy.yml +++ b/.github/workflows/blob-size-policy.yml @@ -10,15 +10,17 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 + persist-credentials: false - name: Determine PR comparison range id: range shell: bash run: | set -euo pipefail - echo "base=$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT" - echo "head=$(git rev-parse HEAD^2)" >> "$GITHUB_OUTPUT" + echo "base=${{ github.event.pull_request.base.sha }}" >> "$GITHUB_OUTPUT" + echo "head=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT" - name: Check changed blob sizes env: diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml index 024198b8d1..f20d09e112 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -15,6 +15,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Install Rust toolchain uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a6ce7058e..a1c60acc26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,6 +13,9 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Verify codex-rs Cargo manifests inherit workspace settings run: python3 .github/scripts/verify_cargo_workspace_manifests.py diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index 52c9438b38..aaa15cf40d 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -19,6 +19,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Annotate locations with typos uses: codespell-project/codespell-problem-matcher@b80729f885d32f78a716c2f107b4db1025001c42 # v1.1.0 - name: Codespell diff --git a/.github/workflows/issue-deduplicator.yml b/.github/workflows/issue-deduplicator.yml index 66148f09b8..11b4e914fe 100644 --- a/.github/workflows/issue-deduplicator.yml +++ b/.github/workflows/issue-deduplicator.yml @@ -20,6 +20,8 @@ jobs: has_matches: ${{ steps.normalize-all.outputs.has_matches }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Prepare Codex inputs env: @@ -156,6 +158,8 @@ jobs: has_matches: ${{ steps.normalize-open.outputs.has_matches }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Prepare Codex inputs env: diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index 5ae456633c..a0bba631cd 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -18,6 +18,8 @@ jobs: codex_output: ${{ steps.codex.outputs.final-message }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - id: codex uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02 # v1.7 diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index 5a5e98ccf7..de37faa4ca 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,13 +39,14 @@ jobs: working-directory: codex-rs steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 with: - tool: cargo-shear - version: 1.11.2 + tool: cargo-shear@1.11.2 - name: cargo shear - run: cargo shear + run: cargo shear --deny-warnings argument_comment_lint_package: name: Argument comment lint package @@ -53,6 +56,8 @@ jobs: DYLINT_LINK_VERSION: 5.0.0 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 with: toolchain: nightly-2025-09-18 @@ -103,6 +108,8 @@ jobs: labels: codex-windows-x64 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: ./.github/actions/setup-bazel-ci with: target: ${{ runner.os }} @@ -239,6 +246,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Install Linux build dependencies if: ${{ runner.os == 'Linux' }} shell: bash @@ -565,6 +574,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Install Linux build dependencies if: ${{ runner.os == 'Linux' }} shell: bash @@ -727,10 +738,12 @@ jobs: shell: bash run: | set +e - if [[ "${{ steps.test.outcome }}" != "success" ]]; then + if [[ "${STEPS_TEST_OUTCOME}" != "success" ]]; then docker logs codex-remote-test-env || true fi docker rm -f codex-remote-test-env >/dev/null 2>&1 || true + env: + STEPS_TEST_OUTCOME: ${{ steps.test.outcome }} - name: verify tests passed if: steps.test.outcome == 'failure' diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index b771e42b27..75c5c33601 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -16,7 +16,9 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} fetch-depth: 0 + persist-credentials: false - name: Detect changed paths (no external action) id: detect shell: bash @@ -62,6 +64,9 @@ jobs: working-directory: codex-rs steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 with: components: rustfmt @@ -78,13 +83,15 @@ jobs: working-directory: codex-rs steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 with: - tool: cargo-shear - version: 1.11.2 + tool: cargo-shear@1.11.2 - name: cargo shear - run: cargo shear + run: cargo shear --deny-warnings argument_comment_lint_package: name: Argument comment lint package @@ -96,6 +103,9 @@ jobs: DYLINT_LINK_VERSION: 5.0.0 steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 - name: Install nightly argument-comment-lint toolchain shell: bash @@ -172,6 +182,9 @@ jobs: echo "run=false" >> "$GITHUB_OUTPUT" - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 if: ${{ steps.argument_comment_lint_gate.outputs.run == 'true' }} + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Run argument comment lint on codex-rs via Bazel if: ${{ steps.argument_comment_lint_gate.outputs.run == 'true' }} uses: ./.github/actions/run-argument-comment-lint @@ -203,20 +216,25 @@ jobs: # If nothing relevant changed (PR touching only root README, etc.), # declare success regardless of other jobs. - if [[ '${{ needs.changed.outputs.argument_comment_lint }}' != 'true' && '${{ needs.changed.outputs.codex }}' != 'true' && '${{ needs.changed.outputs.workflows }}' != 'true' ]]; then + if [[ "${NEEDS_CHANGED_OUTPUTS_ARGUMENT_COMMENT_LINT}" != 'true' && "${NEEDS_CHANGED_OUTPUTS_CODEX}" != 'true' && "${NEEDS_CHANGED_OUTPUTS_WORKFLOWS}" != 'true' ]]; then echo 'No relevant changes -> CI not required.' exit 0 fi - if [[ '${{ needs.changed.outputs.argument_comment_lint_package }}' == 'true' ]]; then + if [[ "${NEEDS_CHANGED_OUTPUTS_ARGUMENT_COMMENT_LINT_PACKAGE}" == 'true' ]]; then [[ '${{ needs.argument_comment_lint_package.result }}' == 'success' ]] || { echo 'argument_comment_lint_package failed'; exit 1; } fi - if [[ '${{ needs.changed.outputs.argument_comment_lint }}' == 'true' || '${{ needs.changed.outputs.workflows }}' == 'true' ]]; then + if [[ "${NEEDS_CHANGED_OUTPUTS_ARGUMENT_COMMENT_LINT}" == 'true' || "${NEEDS_CHANGED_OUTPUTS_WORKFLOWS}" == 'true' ]]; then [[ '${{ needs.argument_comment_lint_prebuilt.result }}' == 'success' ]] || { echo 'argument_comment_lint_prebuilt failed'; exit 1; } fi - if [[ '${{ needs.changed.outputs.codex }}' == 'true' || '${{ needs.changed.outputs.workflows }}' == 'true' ]]; then + if [[ "${NEEDS_CHANGED_OUTPUTS_CODEX}" == 'true' || "${NEEDS_CHANGED_OUTPUTS_WORKFLOWS}" == 'true' ]]; then [[ '${{ needs.general.result }}' == 'success' ]] || { echo 'general failed'; exit 1; } [[ '${{ needs.cargo_shear.result }}' == 'success' ]] || { echo 'cargo_shear failed'; exit 1; } fi + env: + NEEDS_CHANGED_OUTPUTS_ARGUMENT_COMMENT_LINT: ${{ needs.changed.outputs.argument_comment_lint }} + NEEDS_CHANGED_OUTPUTS_CODEX: ${{ needs.changed.outputs.codex }} + NEEDS_CHANGED_OUTPUTS_WORKFLOWS: ${{ needs.changed.outputs.workflows }} + NEEDS_CHANGED_OUTPUTS_ARGUMENT_COMMENT_LINT_PACKAGE: ${{ needs.changed.outputs.argument_comment_lint_package }} diff --git a/.github/workflows/rust-release-argument-comment-lint.yml b/.github/workflows/rust-release-argument-comment-lint.yml index 277dcd122a..f654bd9dd7 100644 --- a/.github/workflows/rust-release-argument-comment-lint.yml +++ b/.github/workflows/rust-release-argument-comment-lint.yml @@ -57,6 +57,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0 with: diff --git a/.github/workflows/rust-release-prepare.yml b/.github/workflows/rust-release-prepare.yml index b478344a2c..e998efb54c 100644 --- a/.github/workflows/rust-release-prepare.yml +++ b/.github/workflows/rust-release-prepare.yml @@ -22,6 +22,7 @@ jobs: with: ref: main fetch-depth: 0 + persist-credentials: false - name: Update models.json env: diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 0c7393c442..8a1b6030a8 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -84,6 +84,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Print runner specs (Windows) shell: powershell run: | @@ -166,6 +168,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Download prebuilt Windows primary binaries uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 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 0b15fe2bdb..154f40620a 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 @@ -536,6 +541,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..0f9065941b 100644 --- a/.github/workflows/sdk.yml +++ b/.github/workflows/sdk.yml @@ -14,6 +14,9 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Install Linux bwrap build dependencies shell: bash diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index a1aeaf4029..6e71367d1f 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -41,6 +41,9 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 @@ -75,6 +78,9 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Set up Bazel uses: ./.github/actions/setup-bazel-ci diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 10b5cc2351..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", @@ -1979,6 +1980,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 +2227,7 @@ dependencies = [ "clap", "clap_complete", "codex-app-server", + "codex-app-server-daemon", "codex-app-server-protocol", "codex-app-server-test-client", "codex-arg0", @@ -2441,7 +2463,11 @@ dependencies = [ "codex-app-server-protocol", "pretty_assertions", "serde", + "serde_json", + "sha1", + "tempfile", "tokio", + "tracing", "urlencoding", ] @@ -2526,7 +2552,6 @@ dependencies = [ "insta", "libc", "maplit", - "notify", "once_cell", "openssl-sys", "opentelemetry", @@ -2733,7 +2758,6 @@ dependencies = [ "serde", "serde_json", "serial_test", - "sha2", "tempfile", "test-case", "thiserror 2.0.18", @@ -2870,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" @@ -3331,7 +3366,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..dd0cd7e491 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", @@ -48,6 +49,7 @@ members = [ "external-agent-sessions", "keyring-store", "file-search", + "file-watcher", "linux-sandbox", "lmstudio", "login", @@ -131,6 +133,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" } @@ -164,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" } @@ -464,11 +468,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/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..2c2f724a95 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; @@ -636,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, }), }, @@ -827,6 +832,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 { @@ -1122,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, }), }, @@ -1269,6 +1475,7 @@ async fn compaction_event_ingests_custom_fact() { }, capabilities: Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), }, @@ -1382,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/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/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index ebafe351af..3b386ff3ce 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -49,7 +49,6 @@ use codex_config::RemoteThreadConfigLoader; use codex_config::ThreadConfigLoader; use codex_core::config::Config; pub use codex_exec_server::EnvironmentManager; -pub use codex_exec_server::EnvironmentManagerArgs; pub use codex_exec_server::ExecServerRuntimePaths; use codex_feedback::CodexFeedback; use codex_protocol::protocol::SessionSource; @@ -375,6 +374,7 @@ impl InProcessClientStartArgs { pub fn initialize_params(&self) -> InitializeParams { let capabilities = InitializeCapabilities { experimental_api: self.experimental_api, + request_attestation: false, opt_out_notification_methods: if self.opt_out_notification_methods.is_empty() { None } else { diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index d75534c160..4a4426a260 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -73,6 +73,7 @@ impl RemoteAppServerConnectArgs { fn initialize_params(&self) -> InitializeParams { let capabilities = InitializeCapabilities { experimental_api: self.experimental_api, + request_attestation: false, opt_out_notification_methods: if self.opt_out_notification_methods.is_empty() { None } else { diff --git a/codex-rs/app-server-daemon/BUILD.bazel b/codex-rs/app-server-daemon/BUILD.bazel new file mode 100644 index 0000000000..1bca6d55db --- /dev/null +++ b/codex-rs/app-server-daemon/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "app-server-daemon", + crate_name = "codex_app_server_daemon", +) diff --git a/codex-rs/app-server-daemon/Cargo.toml b/codex-rs/app-server-daemon/Cargo.toml new file mode 100644 index 0000000000..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/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..6351993046 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1259,6 +1259,11 @@ "array", "null" ] + }, + "requestAttestation": { + "default": false, + "description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.", + "type": "boolean" } }, "type": "object" @@ -2083,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/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/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 156f6ddc4a..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 @@ -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": { @@ -12221,10 +12270,20 @@ "null" ] }, + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "remotePluginId": { "type": "string" }, - "shareTargets": { + "sharePrincipals": { "items": { "$ref": "#/definitions/v2/PluginSharePrincipal" }, @@ -12285,14 +12344,10 @@ }, "plugin": { "$ref": "#/definitions/v2/PluginSummary" - }, - "shareUrl": { - "type": "string" } }, "required": [ - "plugin", - "shareUrl" + "plugin" ], "type": "object" }, @@ -12327,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", @@ -12406,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", @@ -13306,7 +13384,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 +13392,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..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 @@ -6409,6 +6409,11 @@ "array", "null" ] + }, + "requestAttestation": { + "default": false, + "description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.", + "type": "boolean" } }, "type": "object" @@ -8814,10 +8819,20 @@ "null" ] }, + "discoverability": { + "anyOf": [ + { + "$ref": "#/definitions/PluginShareDiscoverability" + }, + { + "type": "null" + } + ] + }, "remotePluginId": { "type": "string" }, - "shareTargets": { + "sharePrincipals": { "items": { "$ref": "#/definitions/PluginSharePrincipal" }, @@ -8878,14 +8893,10 @@ }, "plugin": { "$ref": "#/definitions/PluginSummary" - }, - "shareUrl": { - "type": "string" } }, "required": [ - "plugin", - "shareUrl" + "plugin" ], "type": "object" }, @@ -8920,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", @@ -8999,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", @@ -9899,7 +9933,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 +9941,15 @@ "null" ] }, + "installationId": { + "type": "string" + }, "status": { "$ref": "#/definitions/RemoteControlConnectionStatus" } }, "required": [ + "installationId", "status" ], "title": "RemoteControlStatusChangedNotification", diff --git a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json index 6048b82242..af5c509249 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json @@ -39,6 +39,11 @@ "array", "null" ] + }, + "requestAttestation": { + "default": false, + "description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.", + "type": "boolean" } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/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/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/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/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/RemoteControlStatusChangedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts index 16a9138556..8c63ab9029 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts @@ -4,6 +4,6 @@ import type { RemoteControlConnectionStatus } from "./RemoteControlConnectionStatus"; /** - * Current remote-control connection status and environment id exposed to clients. + * Current remote-control connection status and remote identity exposed to clients. */ -export type RemoteControlStatusChangedNotification = { status: RemoteControlConnectionStatus, environmentId: string | null, }; +export type RemoteControlStatusChangedNotification = { status: RemoteControlConnectionStatus, installationId: string, environmentId: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 3cd919cb9f..a6b961366e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -28,6 +28,8 @@ export type { AppsDefaultConfig } from "./AppsDefaultConfig"; export type { AppsListParams } from "./AppsListParams"; export type { AppsListResponse } from "./AppsListResponse"; export type { AskForApproval } from "./AskForApproval"; +export type { AttestationGenerateParams } from "./AttestationGenerateParams"; +export type { AttestationGenerateResponse } from "./AttestationGenerateResponse"; export type { AutoReviewDecisionSource } from "./AutoReviewDecisionSource"; export type { ByteRange } from "./ByteRange"; export type { CancelLoginAccountParams } from "./CancelLoginAccountParams"; @@ -283,10 +285,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/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 87716e0c9a..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, @@ -1312,6 +1319,12 @@ server_request_definitions! { response: v2::ChatgptAuthTokensRefreshResponse, }, + /// Generate a fresh upstream attestation result on demand. + AttestationGenerate => "attestation/generate" { + params: v2::AttestationGenerateParams, + response: v2::AttestationGenerateResponse, + }, + /// DEPRECATED APIs below /// Request to approve a patch. /// This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage). @@ -1790,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] @@ -1910,6 +1935,7 @@ mod tests { }, capabilities: Some(v1::InitializeCapabilities { experimental_api: true, + request_attestation: true, opt_out_notification_methods: Some(vec![ "thread/started".to_string(), "item/agentMessage/delta".to_string(), @@ -1930,6 +1956,7 @@ mod tests { }, "capabilities": { "experimentalApi": true, + "requestAttestation": true, "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" @@ -1955,6 +1982,7 @@ mod tests { }, "capabilities": { "experimentalApi": true, + "requestAttestation": true, "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" @@ -1975,6 +2003,7 @@ mod tests { }, capabilities: Some(v1::InitializeCapabilities { experimental_api: true, + request_attestation: true, opt_out_notification_methods: Some(vec![ "thread/started".to_string(), "item/agentMessage/delta".to_string(), @@ -2091,6 +2120,28 @@ mod tests { Ok(()) } + #[test] + fn serialize_attestation_generate_request() -> Result<()> { + let params = v2::AttestationGenerateParams {}; + let request = ServerRequest::AttestationGenerate { + request_id: RequestId::Integer(9), + params: params.clone(), + }; + assert_eq!( + json!({ + "method": "attestation/generate", + "id": 9, + "params": {} + }), + serde_json::to_value(&request)?, + ); + + let payload = ServerRequestPayload::AttestationGenerate(params); + assert_eq!(request.id(), &RequestId::Integer(9)); + assert_eq!(payload.request_with_id(RequestId::Integer(9)), request); + Ok(()) + } + #[test] fn serialize_server_response() -> Result<()> { let response = ServerResponse::CommandExecutionRequestApproval { @@ -2546,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"), }, @@ -2557,7 +2631,7 @@ mod tests { assert_eq!( json!({ "method": "fs/getMetadata", - "id": 9, + "id": 10, "params": { "path": absolute_path_string("tmp/example") } @@ -2818,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/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/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 275e7ca45b..b5fa9fdc65 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -2,9 +2,11 @@ mod shared; mod account; mod apps; +mod attestation; mod collaboration_mode; mod command_exec; mod config; +mod environment; mod experimental_feature; mod feedback; mod fs; @@ -26,9 +28,11 @@ mod windows_sandbox; pub use account::*; pub use apps::*; +pub use attestation::*; pub use collaboration_mode::*; pub use command_exec::*; pub use config::*; +pub use environment::*; pub use experimental_feature::*; pub use feedback::*; pub use fs::*; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/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/remote_control.rs b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs index 7d6383f468..7dab24c3d4 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs @@ -3,12 +3,13 @@ use serde::Deserialize; use serde::Serialize; use ts_rs::TS; -/// Current remote-control connection status and environment id exposed to clients. +/// Current remote-control connection status and remote identity exposed to clients. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] pub struct RemoteControlStatusChangedNotification { pub status: RemoteControlConnectionStatus, + pub installation_id: String, pub environment_id: Option, } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 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-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-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/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/README.md b/codex-rs/app-server/README.md index 01982d7ee5..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. @@ -1337,6 +1338,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/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/fs_watch.rs b/codex-rs/app-server/src/fs_watch.rs index 47248451a2..09c6dc5533 100644 --- a/codex-rs/app-server/src/fs_watch.rs +++ b/codex-rs/app-server/src/fs_watch.rs @@ -8,12 +8,12 @@ use codex_app_server_protocol::FsWatchParams; use codex_app_server_protocol::FsWatchResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ServerNotification; -use codex_core::file_watcher::FileWatcher; -use codex_core::file_watcher::FileWatcherEvent; -use codex_core::file_watcher::FileWatcherSubscriber; -use codex_core::file_watcher::Receiver; -use codex_core::file_watcher::WatchPath; -use codex_core::file_watcher::WatchRegistration; +use codex_file_watcher::FileWatcher; +use codex_file_watcher::FileWatcherEvent; +use codex_file_watcher::FileWatcherSubscriber; +use codex_file_watcher::Receiver; +use codex_file_watcher::WatchPath; +use codex_file_watcher::WatchRegistration; use std::collections::HashMap; use std::collections::HashSet; use std::collections::hash_map::Entry; diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 08aab99f65..6999948b04 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -8,7 +8,6 @@ use codex_config::RemoteThreadConfigLoader; use codex_config::ThreadConfigLoader; use codex_core::config::Config; use codex_core::resolve_installation_id; -use codex_exec_server::EnvironmentManagerArgs; use codex_features::Feature; use codex_login::AuthManager; use codex_utils_cli::CliConfigOverrides; @@ -31,6 +30,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; @@ -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; @@ -93,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; @@ -159,22 +161,33 @@ enum ShutdownAction { Finish, } -async fn shutdown_signal() -> IoResult<()> { +#[derive(Clone, Copy)] +enum ShutdownSignal { + Forceable, + #[cfg(unix)] + GracefulOnly, +} + +async fn shutdown_signal() -> IoResult { #[cfg(unix)] { use tokio::signal::unix::SignalKind; use tokio::signal::unix::signal; let mut term = signal(SignalKind::terminate())?; + let mut hangup = signal(SignalKind::hangup())?; tokio::select! { - ctrl_c_result = tokio::signal::ctrl_c() => ctrl_c_result, - _ = term.recv() => Ok(()), + ctrl_c_result = tokio::signal::ctrl_c() => ctrl_c_result.map(|_| ShutdownSignal::Forceable), + _ = term.recv() => Ok(ShutdownSignal::Forceable), + _ = hangup.recv() => Ok(ShutdownSignal::GracefulOnly), } } #[cfg(not(unix))] { - tokio::signal::ctrl_c().await + tokio::signal::ctrl_c() + .await + .map(|_| ShutdownSignal::Forceable) } } @@ -187,9 +200,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; } @@ -419,15 +439,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); @@ -443,6 +454,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(), @@ -686,7 +708,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(), @@ -808,11 +833,15 @@ pub async fn run_main_with_transport_options( tokio::select! { shutdown_signal_result = shutdown_signal(), if graceful_signal_restart_enabled && !shutdown_state.forced() => { - if let Err(err) = shutdown_signal_result { - warn!("failed to listen for shutdown signal during graceful restart drain: {err}"); - } + let signal = match shutdown_signal_result { + Ok(signal) => signal, + Err(err) => { + warn!("failed to listen for shutdown signal during graceful restart drain: {err}"); + continue; + } + }; let running_turn_count = *running_turn_count_rx.borrow(); - shutdown_state.on_signal(connections.len(), running_turn_count); + shutdown_state.on_signal(signal, connections.len(), running_turn_count); } changed = running_turn_count_rx.changed(), if graceful_signal_restart_enabled && shutdown_state.requested() => { if changed.is_err() { @@ -933,7 +962,14 @@ pub async fn run_main_with_transport_options( ), ) .await; - processor.connection_initialized(connection_id).await; + processor + .connection_initialized( + connection_id, + connection_state + .session + .request_attestation(), + ) + .await; connection_state .outbound_initialized .store(true, std::sync::atomic::Ordering::Release); @@ -977,6 +1013,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/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..c8204e05b6 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; @@ -17,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; @@ -34,6 +36,8 @@ use crate::request_processors::WindowsSandboxRequestProcessor; use crate::request_serialization::QueuedInitializedRequest; use crate::request_serialization::RequestSerializationQueueKey; use crate::request_serialization::RequestSerializationQueues; +use crate::skills_watcher::SkillsWatcher; +use crate::thread_state::ConnectionCapabilities; use crate::thread_state::ThreadStateManager; use crate::transport::AppServerTransport; use crate::transport::RemoteControlHandle; @@ -82,6 +86,7 @@ use tokio::time::timeout; use tracing::Instrument; const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); + #[derive(Clone)] struct ExternalAuthRefreshBridge { outgoing: Arc, @@ -158,6 +163,7 @@ pub(crate) struct MessageProcessor { command_exec_processor: CommandExecRequestProcessor, process_exec_processor: ProcessExecRequestProcessor, config_processor: ConfigRequestProcessor, + environment_processor: EnvironmentRequestProcessor, external_agent_config_processor: ExternalAgentConfigRequestProcessor, feedback_processor: FeedbackRequestProcessor, fs_processor: FsRequestProcessor, @@ -186,6 +192,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 +238,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 +293,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 +307,17 @@ 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 skills_watcher = SkillsWatcher::new(thread_manager.skills_manager(), outgoing.clone()); let pending_thread_unloads = Arc::new(Mutex::new(HashSet::new())); - let thread_state_manager = ThreadStateManager::new(); let thread_watch_manager = crate::thread_status::ThreadWatchManager::new_with_outgoing(outgoing.clone()); let thread_list_state_permit = Arc::new(Semaphore::new(/*permits*/ 1)); @@ -389,6 +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(), @@ -402,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. @@ -432,6 +452,8 @@ impl MessageProcessor { arg0_paths, config.codex_home.to_path_buf(), ); + let environment_processor = + EnvironmentRequestProcessor::new(thread_manager.environment_manager()); let fs_processor = FsRequestProcessor::new( thread_manager .environment_manager() @@ -453,6 +475,7 @@ impl MessageProcessor { command_exec_processor, process_exec_processor, config_processor, + environment_processor, external_agent_config_processor, feedback_processor, fs_processor, @@ -620,9 +643,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 +750,12 @@ impl MessageProcessor { .await?; if connection_initialized { self.thread_processor - .connection_initialized(connection_id) + .connection_initialized( + connection_id, + ConnectionCapabilities { + request_attestation: session.request_attestation(), + }, + ) .await; } return Ok(()); @@ -850,6 +887,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/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..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; @@ -49,6 +50,8 @@ use codex_app_server_protocol::ConversationGitInfo; use codex_app_server_protocol::ConversationSummary; use codex_app_server_protocol::DeprecationNoticeNotification; use codex_app_server_protocol::DynamicToolSpec as ApiDynamicToolSpec; +use codex_app_server_protocol::EnvironmentAddParams; +use codex_app_server_protocol::EnvironmentAddResponse; use codex_app_server_protocol::ExperimentalFeature as ApiExperimentalFeature; use codex_app_server_protocol::ExperimentalFeatureListParams; use codex_app_server_protocol::ExperimentalFeatureListResponse; @@ -434,6 +437,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 +458,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; @@ -473,6 +478,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/environment_processor.rs b/codex-rs/app-server/src/request_processors/environment_processor.rs new file mode 100644 index 0000000000..eb9b283f7b --- /dev/null +++ b/codex-rs/app-server/src/request_processors/environment_processor.rs @@ -0,0 +1,24 @@ +use super::*; + +#[derive(Clone)] +pub(crate) struct EnvironmentRequestProcessor { + environment_manager: Arc, +} + +impl EnvironmentRequestProcessor { + pub(crate) fn new(environment_manager: Arc) -> Self { + Self { + environment_manager, + } + } + + pub(crate) async fn environment_add( + &self, + params: EnvironmentAddParams, + ) -> Result, JSONRPCErrorError> { + self.environment_manager + .upsert_environment(params.environment_id, params.exec_server_url) + .map_err(|err| invalid_request(err.to_string()))?; + Ok(Some(EnvironmentAddResponse {}.into())) + } +} diff --git a/codex-rs/app-server/src/request_processors/initialize_processor.rs b/codex-rs/app-server/src/request_processors/initialize_processor.rs index a206b2faa0..c33af189cf 100644 --- a/codex-rs/app-server/src/request_processors/initialize_processor.rs +++ b/codex-rs/app-server/src/request_processors/initialize_processor.rs @@ -13,6 +13,8 @@ use super::*; use crate::message_processor::ConnectionSessionState; use crate::message_processor::InitializedConnectionSessionState; +const DAEMON_PROBE_CLIENT_NAME: &str = "codex_app_server_daemon"; + #[derive(Clone)] pub(crate) struct InitializeRequestProcessor { outgoing: Arc, @@ -65,15 +67,17 @@ impl InitializeRequestProcessor { // experimental API). Proposed direction is instance-global first-write-wins // with initialize-time mismatch rejection. let analytics_initialize_params = params.clone(); - let (experimental_api_enabled, opt_out_notification_methods) = match params.capabilities { - Some(capabilities) => ( - capabilities.experimental_api, - capabilities - .opt_out_notification_methods - .unwrap_or_default(), - ), - None => (false, Vec::new()), - }; + let (experimental_api_enabled, request_attestation, opt_out_notification_methods) = + match params.capabilities { + Some(capabilities) => ( + capabilities.experimental_api, + capabilities.request_attestation, + capabilities + .opt_out_notification_methods + .unwrap_or_default(), + ), + None => (false, false, Vec::new()), + }; let ClientInfo { name, title: _title, @@ -88,6 +92,7 @@ impl InitializeRequestProcessor { } let originator = name.clone(); let user_agent_suffix = format!("{name}; {version}"); + let mutates_global_identity = name != DAEMON_PROBE_CLIENT_NAME; let codex_home = self.config.codex_home.clone(); if session .initialize(InitializedConnectionSessionState { @@ -95,27 +100,29 @@ impl InitializeRequestProcessor { opted_out_notification_methods: opt_out_notification_methods.into_iter().collect(), app_server_client_name: name.clone(), client_version: version, + request_attestation, }) .is_err() { return Err(invalid_request("Already initialized")); } - // Only the request that wins session initialization may mutate - // process-global client metadata. - if let Err(error) = set_default_originator(originator.clone()) { - match error { - SetOriginatorError::InvalidHeaderValue => { - tracing::warn!( - client_info_name = %name, - "validated clientInfo.name was rejected while setting originator" - ); - } - SetOriginatorError::AlreadyInitialized => { - // No-op. This is expected to happen if the originator is already set via env var. - // TODO(owen): Once we remove support for CODEX_INTERNAL_ORIGINATOR_OVERRIDE, - // this will be an unexpected state and we can return a JSON-RPC error indicating - // internal server error. + if mutates_global_identity { + // Only real client initialization may mutate process-global client metadata. + if let Err(error) = set_default_originator(originator.clone()) { + match error { + SetOriginatorError::InvalidHeaderValue => { + tracing::warn!( + client_info_name = %name, + "validated clientInfo.name was rejected while setting originator" + ); + } + SetOriginatorError::AlreadyInitialized => { + // No-op. This is expected to happen if the originator is already set via env var. + // TODO(owen): Once we remove support for CODEX_INTERNAL_ORIGINATOR_OVERRIDE, + // this will be an unexpected state and we can return a JSON-RPC error indicating + // internal server error. + } } } } @@ -126,7 +133,7 @@ impl InitializeRequestProcessor { self.rpc_transport, ); set_default_client_residency_requirement(self.config.enforce_residency.value()); - if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() { + if mutates_global_identity && let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() { *suffix = Some(user_agent_suffix); } diff --git a/codex-rs/app-server/src/request_processors/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/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 615e37f2c9..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, @@ -2230,9 +2234,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/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index bdc5847b0d..d1dae4ef46 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -13,6 +13,7 @@ pub(crate) struct TurnRequestProcessor { thread_state_manager: ThreadStateManager, thread_watch_manager: ThreadWatchManager, thread_list_state_permit: Arc, + skills_watcher: Arc, } impl TurnRequestProcessor { @@ -29,6 +30,7 @@ impl TurnRequestProcessor { thread_state_manager: ThreadStateManager, thread_watch_manager: ThreadWatchManager, thread_list_state_permit: Arc, + skills_watcher: Arc, ) -> Self { Self { auth_manager, @@ -42,6 +44,7 @@ impl TurnRequestProcessor { thread_state_manager, thread_watch_manager, thread_list_state_permit, + skills_watcher, } } @@ -1087,6 +1090,7 @@ impl TurnRequestProcessor { thread_list_state_permit: self.thread_list_state_permit.clone(), fallback_model_provider: self.config.model_provider_id.clone(), codex_home: self.config.codex_home.to_path_buf(), + skills_watcher: Arc::clone(&self.skills_watcher), } } diff --git a/codex-rs/app-server/src/skills_watcher.rs b/codex-rs/app-server/src/skills_watcher.rs new file mode 100644 index 0000000000..d20628d386 --- /dev/null +++ b/codex-rs/app-server/src/skills_watcher.rs @@ -0,0 +1,112 @@ +use std::sync::Arc; +use std::time::Duration; + +use crate::outgoing_message::OutgoingMessageSender; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::SkillsChangedNotification; +use codex_core::ThreadManager; +use codex_core::config::Config; +use codex_core::skills::SkillsLoadInput; +use codex_core::skills::SkillsManager; +use codex_file_watcher::FileWatcher; +use codex_file_watcher::FileWatcherSubscriber; +use codex_file_watcher::Receiver; +use codex_file_watcher::ThrottledWatchReceiver; +use codex_file_watcher::WatchPath; +use codex_file_watcher::WatchRegistration; +use codex_protocol::protocol::TurnEnvironmentSelection; +use tracing::warn; + +#[cfg(not(test))] +const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_secs(10); +#[cfg(test)] +const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_millis(50); + +pub(crate) struct SkillsWatcher { + subscriber: FileWatcherSubscriber, +} + +impl SkillsWatcher { + pub(crate) fn new( + skills_manager: Arc, + outgoing: Arc, + ) -> Arc { + let file_watcher = match FileWatcher::new() { + Ok(file_watcher) => Arc::new(file_watcher), + Err(err) => { + warn!("failed to initialize skills file watcher: {err}"); + Arc::new(FileWatcher::noop()) + } + }; + let (subscriber, rx) = file_watcher.add_subscriber(); + Self::spawn_event_loop(rx, skills_manager, outgoing); + Arc::new(Self { subscriber }) + } + + pub(crate) async fn register_thread_config( + &self, + config: &Config, + thread_manager: &ThreadManager, + environments: &[TurnEnvironmentSelection], + ) -> WatchRegistration { + let Some(environment_selection) = environments.first() else { + return WatchRegistration::default(); + }; + let Some(environment) = thread_manager + .environment_manager() + .get_environment(&environment_selection.environment_id) + else { + warn!( + "failed to register skills watcher for unknown environment `{}`", + environment_selection.environment_id + ); + return WatchRegistration::default(); + }; + if environment.is_remote() { + return WatchRegistration::default(); + } + + let plugins_input = config.plugins_config_input(); + let plugins_manager = thread_manager.plugins_manager(); + let plugin_outcome = plugins_manager.plugins_for_config(&plugins_input).await; + let skills_input = SkillsLoadInput::new( + config.cwd.clone(), + plugin_outcome.effective_plugin_skill_roots(), + config.config_layer_stack.clone(), + config.bundled_skills_enabled(), + ); + let roots = thread_manager + .skills_manager() + .skill_roots_for_config(&skills_input, Some(environment.get_filesystem())) + .await + .into_iter() + .map(|root| WatchPath { + path: root.path.into_path_buf(), + recursive: true, + }) + .collect(); + self.subscriber.register_paths(roots) + } + + fn spawn_event_loop( + rx: Receiver, + skills_manager: Arc, + outgoing: Arc, + ) { + let mut rx = ThrottledWatchReceiver::new(rx, WATCHER_THROTTLE_INTERVAL); + let Ok(handle) = tokio::runtime::Handle::try_current() else { + warn!("skills watcher listener skipped: no Tokio runtime available"); + return; + }; + handle.spawn(async move { + while rx.recv().await.is_some() { + skills_manager.clear_cache(); + outgoing + .send_server_notification(ServerNotification::SkillsChanged( + SkillsChangedNotification {}, + )) + .await; + } + }); + } +} diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index dddbcf483b..f0dbb0e326 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -7,6 +7,7 @@ use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError; use codex_core::CodexThread; use codex_core::ThreadConfigSnapshot; +use codex_file_watcher::WatchRegistration; use codex_protocol::ThreadId; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; @@ -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) { @@ -199,11 +204,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 +224,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 +372,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 +400,7 @@ impl ThreadStateManager { connection_id: ConnectionId, ) -> bool { let mut state = self.state.lock().await; - if !state.live_connections.contains(&connection_id) { + if !state.live_connections.contains_key(&connection_id) { return false; } state diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport.rs index 8d61ac5f56..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; diff --git a/codex-rs/app-server/tests/suite/v2/attestation.rs b/codex-rs/app-server/tests/suite/v2/attestation.rs new file mode 100644 index 0000000000..d0565e2571 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/attestation.rs @@ -0,0 +1,194 @@ +use anyhow::Result; +use anyhow::bail; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::AttestationGenerateResponse; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_config::types::AuthCredentialsStoreMode; +use core_test_support::responses; +use core_test_support::responses::WebSocketConnectionConfig; +use core_test_support::responses::start_websocket_server_with_headers; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::Duration; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60); +const ATTESTATION_HEADER: &str = "v1.integration-test"; +const APP_SERVER_ATTESTATION_HEADER: &str = r#"{"v":1,"s":0,"t":"v1.integration-test"}"#; + +#[tokio::test] +async fn attestation_generate_round_trip_adds_header_to_responses_websocket_handshake() -> Result<()> +{ + skip_if_no_network!(Ok(())); + + let websocket_server = start_websocket_server_with_headers(vec![ + // App-server refreshes `/models` over HTTP during thread startup. It points at the same + // local test base URL, so let that non-websocket probe consume one connection before the + // websocket handshake under test arrives. + WebSocketConnectionConfig { + requests: Vec::new(), + response_headers: Vec::new(), + accept_delay: None, + close_after_requests: true, + }, + WebSocketConnectionConfig { + requests: vec![ + vec![ + responses::ev_response_created("warm-1"), + responses::ev_completed("warm-1"), + ], + vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ], + ], + response_headers: Vec::new(), + accept_delay: None, + close_after_requests: true, + }, + ]) + .await; + + let codex_home = TempDir::new()?; + create_chatgpt_websocket_config( + codex_home.path(), + &websocket_server.uri().replacen("ws://", "http://", 1), + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt").plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + let initialized = timeout( + DEFAULT_READ_TIMEOUT, + mcp.initialize_with_capabilities( + ClientInfo { + name: "codex_desktop".to_string(), + title: Some("Codex Desktop".to_string()), + version: "0.1.0".to_string(), + }, + Some(InitializeCapabilities { + experimental_api: true, + request_attestation: true, + opt_out_notification_methods: None, + }), + ), + ) + .await??; + let JSONRPCMessage::Response(_) = initialized else { + bail!("expected initialize response, got {initialized:?}"); + }; + + let thread_request_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_request_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response(thread_response)?; + + let turn_request_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_request_id)), + ) + .await??; + let _: TurnStartResponse = to_response(turn_response)?; + + let mut attestation_requests = 0; + timeout(DEFAULT_READ_TIMEOUT, async { + loop { + match mcp.read_next_message().await? { + JSONRPCMessage::Request(request) => { + let request = ServerRequest::try_from(request)?; + let ServerRequest::AttestationGenerate { request_id, .. } = request else { + bail!("expected attestation/generate request, got {request:?}"); + }; + attestation_requests += 1; + mcp.send_response( + request_id, + serde_json::to_value(AttestationGenerateResponse { + token: ATTESTATION_HEADER.to_string(), + })?, + ) + .await?; + } + JSONRPCMessage::Notification(notification) + if notification.method == "turn/completed" => + { + break Ok(()); + } + _ => {} + } + } + }) + .await??; + assert!(attestation_requests > 0); + + assert!( + websocket_server + .wait_for_handshakes(/*expected*/ 1, DEFAULT_READ_TIMEOUT) + .await + ); + let handshake = websocket_server.single_handshake(); + assert_eq!( + handshake.header("x-oai-attestation").as_deref(), + Some(APP_SERVER_ATTESTATION_HEADER) + ); + + websocket_server.shutdown().await; + Ok(()) +} + +fn create_chatgpt_websocket_config(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock ChatGPT provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +requires_openai_auth = true +supports_websockets = true +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket_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/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..3d3a473fab 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -62,6 +62,33 @@ async fn initialize_uses_client_info_name_as_originator() -> Result<()> { Ok(()) } +#[tokio::test] +async fn initialize_probe_does_not_override_originator() -> Result<()> { + let responses = Vec::new(); + let server = create_mock_responses_server_sequence_unchecked(responses).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + let message = timeout( + DEFAULT_READ_TIMEOUT, + mcp.initialize_with_client_info(ClientInfo { + name: "codex_app_server_daemon".to_string(), + title: Some("Codex App Server Daemon".to_string()), + version: "0.1.0".to_string(), + }), + ) + .await??; + + let JSONRPCMessage::Response(response) = message else { + anyhow::bail!("expected initialize response, got {message:?}"); + }; + let InitializeResponse { user_agent, .. } = to_response::(response)?; + + assert!(user_agent.starts_with("codex_cli_rs/")); + Ok(()) +} + #[tokio::test] async fn initialize_respects_originator_override_env_var() -> Result<()> { let responses = Vec::new(); @@ -158,6 +185,7 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu }, Some(InitializeCapabilities { experimental_api: true, + request_attestation: false, opt_out_notification_methods: Some(vec!["thread/started".to_string()]), }), ), diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 8e13df7825..642be8ad4a 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -1,6 +1,7 @@ mod account; mod analytics; mod app_list; +mod attestation; mod client_metadata; mod collaboration_mode_list; #[cfg(unix)] diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index ea8294671b..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/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/app-server/tests/suite/v2/thread_status.rs b/codex-rs/app-server/tests/suite/v2/thread_status.rs index ad90e4900a..957969c3ea 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_status.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_status.rs @@ -145,6 +145,7 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> { }, Some(InitializeCapabilities { experimental_api: true, + request_attestation: false, opt_out_notification_methods: Some(vec!["thread/status/changed".to_string()]), }), ), diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 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/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index cbeb4fd1b7..2e54192e85 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -4,7 +4,8 @@ use std::time::Duration; use crate::chatgpt_client::chatgpt_get_request_with_timeout; use codex_app_server_protocol::AppInfo; -use codex_connectors::AllConnectorsCacheKey; +use codex_connectors::ConnectorDirectoryCacheContext; +use codex_connectors::ConnectorDirectoryCacheKey; use codex_connectors::DirectoryListResponse; use codex_connectors::filter::filter_disallowed_connectors; use codex_connectors::merge::merge_connectors; @@ -75,8 +76,8 @@ pub async fn list_cached_all_connectors(config: &Config) -> Option> } let auth = connector_auth(config).await.ok()?; - let cache_key = all_connectors_cache_key(config, &auth); - let connectors = codex_connectors::cached_all_connectors(&cache_key)?; + let cache_context = connector_directory_cache_context(config, &auth); + let connectors = codex_connectors::cached_directory_connectors(&cache_context)?; let connectors = merge_plugin_connectors( connectors, plugin_apps_for_config(config) @@ -98,9 +99,9 @@ pub async fn list_all_connectors_with_options( return Ok(Vec::new()); } let auth = connector_auth(config).await?; - let cache_key = all_connectors_cache_key(config, &auth); + let cache_context = connector_directory_cache_context(config, &auth); let connectors = codex_connectors::list_all_connectors_with_options( - cache_key, + cache_context, auth.is_workspace_account(), force_refetch, |path| async move { @@ -126,12 +127,18 @@ pub async fn list_all_connectors_with_options( )) } -fn all_connectors_cache_key(config: &Config, auth: &CodexAuth) -> AllConnectorsCacheKey { - AllConnectorsCacheKey::new( - config.chatgpt_base_url.clone(), - auth.get_account_id(), - auth.get_chatgpt_user_id(), - auth.is_workspace_account(), +fn connector_directory_cache_context( + config: &Config, + auth: &CodexAuth, +) -> ConnectorDirectoryCacheContext { + ConnectorDirectoryCacheContext::new( + config.codex_home.to_path_buf(), + ConnectorDirectoryCacheKey::new( + config.chatgpt_base_url.clone(), + auth.get_account_id(), + auth.get_chatgpt_user_id(), + auth.is_workspace_account(), + ), ) } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 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( 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/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/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-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-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, } ] 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/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..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), @@ -99,6 +98,7 @@ pub(crate) async fn run_codex_thread_interactive( environment_selections: parent_ctx.environments.clone(), analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), thread_store: Arc::clone(&parent_session.services.thread_store), + attestation_provider: parent_session.services.attestation_provider.clone(), })) .or_cancel(&cancel_token) .await??; diff --git a/codex-rs/core/src/codex_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/connectors.rs b/codex-rs/core/src/connectors.rs index 4da588edb6..7a66e4ffa6 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -6,22 +6,18 @@ use std::sync::Mutex as StdMutex; use std::time::Duration; use std::time::Instant; -use anyhow::Context; use async_channel::unbounded; -use codex_api::SharedAuthProvider; pub use codex_app_server_protocol::AppBranding; pub use codex_app_server_protocol::AppInfo; pub use codex_app_server_protocol::AppMetadata; -use codex_connectors::AllConnectorsCacheKey; -use codex_connectors::DirectoryListResponse; +use codex_connectors::ConnectorDirectoryCacheContext; +use codex_connectors::ConnectorDirectoryCacheKey; use codex_exec_server::EnvironmentManager; -use codex_exec_server::EnvironmentManagerArgs; use codex_exec_server::ExecServerRuntimePaths; use codex_protocol::models::PermissionProfile; use codex_tools::DiscoverableTool; use rmcp::model::ToolAnnotations; use serde::Deserialize; -use serde::de::DeserializeOwned; use tracing::warn; use crate::config::Config; @@ -36,7 +32,6 @@ use codex_core_plugins::PluginsManager; use codex_features::Feature; use codex_login::AuthManager; use codex_login::CodexAuth; -use codex_login::default_client::create_client; use codex_login::default_client::originator; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::McpConnectionManager; @@ -49,7 +44,6 @@ use codex_mcp::host_owned_codex_apps_enabled; use codex_mcp::with_codex_apps_mcp; const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30); -const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct AppToolPolicy { @@ -120,9 +114,11 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( auth: Option<&CodexAuth>, accessible_connectors: &[AppInfo], ) -> anyhow::Result> { - let directory_connectors = - list_directory_connectors_for_tool_suggest_with_auth(config, auth).await?; let connector_ids = tool_suggest_connector_ids(config).await; + let directory_connectors = codex_connectors::merge::merge_plugin_connectors( + cached_directory_connectors_for_tool_suggest_with_auth(config, auth).await, + connector_ids.iter().cloned(), + ); let discoverable_connectors = codex_connectors::filter::filter_tool_suggest_discoverable_connectors( directory_connectors, @@ -202,7 +198,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status( config.codex_linux_sandbox_exe.clone(), )?; let environment_manager = - EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await; + EnvironmentManager::from_codex_home(config.codex_home.clone(), local_runtime_paths).await?; list_accessible_connectors_from_mcp_tools_with_environment_manager( config, force_refetch, @@ -436,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; @@ -454,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( 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/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index b4bd9cbe89..89808c27ee 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -15,12 +15,12 @@ pub(crate) fn default_thread_environment_selections( cwd: &AbsolutePathBuf, ) -> Vec { environment_manager - .default_environment_id() + .default_environment_ids() + .into_iter() .map(|environment_id| TurnEnvironmentSelection { - environment_id: environment_id.to_string(), + environment_id, cwd: cwd.clone(), }) - .into_iter() .collect() } @@ -85,6 +85,7 @@ pub(crate) fn resolve_environment_selections( #[cfg(test)] mod tests { use codex_exec_server::ExecServerRuntimePaths; + use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_exec_server::REMOTE_ENVIRONMENT_ID; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_utils_absolute_path::AbsolutePathBuf; @@ -118,6 +119,38 @@ mod tests { ); } + #[tokio::test] + async fn toml_default_thread_environment_selections_include_local_and_remote() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + std::fs::write( + temp_dir.path().join("environments.toml"), + r#" +[[environments]] +id = "remote" +url = "ws://127.0.0.1:8765" +"#, + ) + .expect("write environments.toml"); + let cwd = AbsolutePathBuf::current_dir().expect("cwd"); + let manager = EnvironmentManager::from_codex_home(temp_dir.path(), test_runtime_paths()) + .await + .expect("environment manager"); + + assert_eq!( + default_thread_environment_selections(&manager, &cwd), + vec![ + TurnEnvironmentSelection { + environment_id: LOCAL_ENVIRONMENT_ID.to_string(), + cwd: cwd.clone(), + }, + TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd, + }, + ] + ); + } + #[tokio::test] async fn default_thread_environment_selections_empty_when_default_disabled() { let cwd = AbsolutePathBuf::current_dir().expect("cwd"); diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 0cdf0e2d46..7b3de524c5 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; @@ -34,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; @@ -99,7 +99,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; @@ -177,6 +176,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; @@ -191,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/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/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index 8717427afe..688ce27508 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,11 +44,16 @@ 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(), 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..7e4c8d7073 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; @@ -112,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; @@ -282,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; @@ -392,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, @@ -412,6 +411,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 = ""; @@ -455,7 +455,6 @@ impl Codex { skills_manager, plugins_manager, mcp_manager, - skills_watcher, conversation_history, session_source, thread_source, @@ -471,6 +470,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(); @@ -650,12 +650,12 @@ impl Codex { skills_manager, plugins_manager, mcp_manager.clone(), - skills_watcher, agent_control, environment_manager, analytics_events_client, thread_store, parent_rollout_thread_trace, + attestation_provider, ) .await .map_err(|e| { @@ -784,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() } @@ -1017,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 f72a173c80..23d22f94a5 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -364,12 +364,12 @@ impl Session { skills_manager: Arc, plugins_manager: Arc, mcp_manager: Arc, - skills_watcher: Arc, agent_control: AgentControl, environment_manager: Arc, analytics_events_client: Option, thread_store: Arc, parent_rollout_thread_trace: ThreadTraceContext, + attestation_provider: Option>, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -845,13 +845,13 @@ 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), 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 +863,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, @@ -932,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 b63b16cbf4..6a5b08c124 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() } @@ -1025,7 +1026,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 @@ -3724,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, @@ -3733,6 +3733,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; @@ -3835,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, @@ -3871,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), @@ -3881,6 +3880,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 +3892,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()), @@ -4060,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, @@ -4069,6 +4069,7 @@ async fn make_session_with_config_and_rx( /*state_db*/ None, )), codex_rollout_trace::ThreadTraceContext::disabled(), + /*attestation_provider*/ None, ) .await?; @@ -4162,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, @@ -4178,6 +4178,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( ), )), codex_rollout_trace::ThreadTraceContext::disabled(), + /*attestation_provider*/ None, ) .await?; @@ -5550,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, @@ -5586,7 +5586,6 @@ where skills_manager, plugins_manager, mcp_manager, - skills_watcher, agent_control, network_proxy: None, network_approval: Arc::clone(&network_approval), @@ -5596,6 +5595,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 +5607,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()), @@ -5895,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] diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 5c473ef1f9..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(), @@ -763,6 +761,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/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 9cd9e97fbb..7e13eddb76 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -3,13 +3,13 @@ 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; 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; @@ -59,13 +59,13 @@ 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, 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/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/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 003f2786b0..bfd91f68f8 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1,11 +1,11 @@ use crate::SkillsManager; use crate::agent::AgentControl; +use crate::attestation::AttestationProvider; use crate::codex_thread::CodexThread; 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; @@ -13,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; @@ -70,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; @@ -105,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 { @@ -246,8 +201,8 @@ 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, installation_id: String, analytics_events_client: Option, @@ -291,6 +246,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(); @@ -305,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())), @@ -315,8 +270,8 @@ impl ThreadManager { skills_manager, plugins_manager, mcp_manager, - skills_watcher, thread_store, + attestation_provider, auth_manager, session_source, installation_id, @@ -395,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( @@ -416,8 +370,8 @@ impl ThreadManager { skills_manager, plugins_manager, mcp_manager, - skills_watcher, thread_store, + attestation_provider: None, auth_manager, session_source: SessionSource::Exec, installation_id, @@ -1160,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; @@ -1188,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, @@ -1204,10 +1144,11 @@ 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 - .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 @@ -1222,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 { @@ -1243,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/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 0834c18e21..21fa03ad7f 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; @@ -292,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(); @@ -318,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, @@ -333,10 +334,107 @@ 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] +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] @@ -401,6 +499,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"); @@ -517,6 +616,7 @@ async fn explicit_installation_id_skips_codex_home_file() { thread_store, state_db.clone(), installation_id.clone(), + /*attestation_provider*/ None, ); let thread = manager @@ -554,6 +654,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 @@ -609,6 +710,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 @@ -671,6 +773,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 @@ -759,6 +862,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 @@ -860,6 +964,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; @@ -1074,6 +1179,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 @@ -1180,6 +1286,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 @@ -1275,6 +1382,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 @@ -1416,6 +1524,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/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/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 2b63c1cb17..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. @@ -454,7 +412,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 +524,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/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/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/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/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/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/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..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; @@ -16,16 +15,10 @@ 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; -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; @@ -272,7 +265,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 +318,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 +335,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, @@ -378,10 +371,9 @@ 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( - &display_name, + tool_name_flat.as_ref(), &call_id_owned, log_payload.as_ref(), &metric_tags, @@ -410,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 { @@ -440,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( @@ -573,145 +549,12 @@ 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}"), } } -// 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 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: invocation.tool_name.display(), - 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.display(), - 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.display(), - 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/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..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 @@ -343,7 +339,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/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 c348d76481..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/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/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/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/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/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 ad3280ebf0..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; @@ -65,6 +64,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/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/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/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-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/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 d13ba6d3bc..7e4a3fb056 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)), } } @@ -114,6 +115,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, @@ -133,10 +143,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( @@ -146,19 +153,41 @@ impl EnvironmentManager { let EnvironmentProviderSnapshot { environments, default, + include_local, } = snapshot; - for id in environments.keys() { + 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() + { + 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" ))); @@ -166,15 +195,9 @@ impl EnvironmentManager { Some(environment_id) } }; - 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: RwLock::new(environment_map), local_environment, }) } @@ -191,6 +214,26 @@ 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 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( + 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) @@ -198,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(()) } } @@ -292,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, ) } @@ -302,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())); @@ -350,7 +432,6 @@ impl Environment { #[cfg(test)] mod tests { - use std::collections::HashMap; use std::sync::Arc; use super::Environment; @@ -371,10 +452,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()) } } @@ -403,14 +481,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] @@ -445,12 +524,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()); } @@ -472,12 +546,13 @@ 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()), + include_local: false, }, }; let manager = EnvironmentManager::from_provider(&provider, test_runtime_paths()) @@ -502,8 +577,9 @@ 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, + include_local: false, }, }; let err = EnvironmentManager::from_provider(&provider, test_runtime_paths()) @@ -516,22 +592,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: HashMap::from([ - ( - 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()) @@ -539,6 +632,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,11 +643,13 @@ mod tests { async fn environment_manager_disables_provider_default() { let provider = TestEnvironmentProvider { snapshot: EnvironmentProviderSnapshot { - environments: HashMap::from([( - LOCAL_ENVIRONMENT_ID.to_string(), - Environment::default_for_tests(), - )]), + environments: vec![( + "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()) @@ -559,18 +658,25 @@ 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] async fn environment_manager_rejects_unknown_provider_default() { let provider = TestEnvironmentProvider { snapshot: EnvironmentProviderSnapshot { - environments: HashMap::from([( - LOCAL_ENVIRONMENT_ID.to_string(), - Environment::default_for_tests(), - )]), + environments: vec![( + "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()) @@ -584,20 +690,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] @@ -609,7 +718,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( @@ -620,7 +729,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)); } @@ -633,20 +742,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] @@ -656,6 +761,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/environment_provider.rs b/codex-rs/exec-server/src/environment_provider.rs index 0e4bcc5191..7e132ee2b4 100644 --- a/codex-rs/exec-server/src/environment_provider.rs +++ b/codex-rs/exec-server/src/environment_provider.rs @@ -1,10 +1,7 @@ -use std::collections::HashMap; - 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; @@ -12,22 +9,21 @@ 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 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: HashMap, + pub environments: Vec<(String, Environment)>, pub default: EnvironmentDefault, + pub include_local: bool, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -53,26 +49,24 @@ 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 = HashMap::from([( - 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.insert( + 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 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()) @@ -81,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()) } } @@ -105,85 +97,82 @@ 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::*; - 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 environments = snapshot.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!( - snapshot.default, + default, EnvironmentDefault::EnvironmentId(LOCAL_ENVIRONMENT_ID.to_string()) ); } #[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 environments = snapshot.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!( - snapshot.default, + default, EnvironmentDefault::EnvironmentId(LOCAL_ENVIRONMENT_ID.to_string()) ); } #[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 environments = snapshot.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!(snapshot.default, EnvironmentDefault::Disabled); + assert_eq!(default, EnvironmentDefault::Disabled); } #[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 environments = snapshot.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!( @@ -191,7 +180,7 @@ mod tests { Some("ws://127.0.0.1:8765") ); assert_eq!( - snapshot.default, + default, EnvironmentDefault::EnvironmentId(REMOTE_ENVIRONMENT_ID.to_string()) ); } @@ -199,14 +188,11 @@ 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 environments = provider - .snapshot(&runtime_paths) - .await - .expect("environments"); + let snapshot = provider.snapshot().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..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,7 +12,8 @@ use crate::DefaultEnvironmentProvider; use crate::Environment; use crate::EnvironmentProvider; use crate::ExecServerError; -use crate::ExecServerRuntimePaths; +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; @@ -39,12 +41,16 @@ 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)] struct TomlEnvironmentProvider { default: EnvironmentDefault, - environments: HashMap, + environments: Vec<(String, ExecServerTransportParams)>, } impl TomlEnvironmentProvider { @@ -58,7 +64,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 +72,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 { @@ -78,28 +84,22 @@ impl TomlEnvironmentProvider { #[async_trait] impl EnvironmentProvider for TomlEnvironmentProvider { - async fn snapshot( - &self, - local_runtime_paths: &ExecServerRuntimePaths, - ) -> Result { - let mut environments = HashMap::from([( - 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.insert( + environments.push(( id.clone(), Environment::remote_with_transport( transport_params.clone(), - Some(local_runtime_paths.clone()), + /*local_runtime_paths*/ None, ), - ); + )); } Ok(EnvironmentProviderSnapshot { environments, default: self.default.clone(), + include_local: true, }) } } @@ -115,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()) { @@ -122,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(); @@ -136,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!( @@ -285,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; @@ -292,25 +326,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() { - 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, - }); + async fn toml_provider_includes_local_and_adds_configured_environments() { let provider = TomlEnvironmentProvider::new(EnvironmentsToml { default: Some("ssh-dev".to_string()), environments: vec![ @@ -335,23 +352,26 @@ 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!["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") ); - 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!( @@ -363,11 +383,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()) @@ -381,11 +399,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); } @@ -449,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 { @@ -483,13 +508,60 @@ mod tests { .expect("provider"); assert_eq!( - provider.environments["ssh-dev"], - ExecServerTransportParams::StdioCommand(StdioExecServerCommand { - program: "ssh".to_string(), - args: Vec::new(), - env: HashMap::new(), - cwd: Some(config_dir.path().join("workspace")), - }) + provider.environments[0].1, + 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), + } ); } @@ -584,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" @@ -600,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 { @@ -682,12 +765,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!(snapshot.environments.contains_key(LOCAL_ENVIRONMENT_ID)); + assert!(snapshot.include_local); + assert!(!environment_ids.contains(&LOCAL_ENVIRONMENT_ID.to_string())); assert_eq!(snapshot.default, EnvironmentDefault::Disabled); } @@ -698,11 +784,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!(snapshot.environments.contains_key(LOCAL_ENVIRONMENT_ID)); + assert!(snapshot.include_local); + assert!(!environment_ids.contains(&LOCAL_ENVIRONMENT_ID.to_string())); + assert_eq!( + snapshot.default, + EnvironmentDefault::EnvironmentId(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-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(), } 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/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b035a19517..e68c96d00b 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, @@ -1604,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/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/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 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; 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); - } } 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/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/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/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/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/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..6856b80b71 100644 --- a/codex-rs/model-provider-info/src/lib.rs +++ b/codex-rs/model-provider-info/src/lib.rs @@ -34,10 +34,13 @@ 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 = "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"; @@ -234,7 +237,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" }; @@ -364,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); 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/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/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": [ 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/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/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/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) 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(_) 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(); diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 6817f677e6..68a95bd2d9 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, @@ -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/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) ); } 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/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/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..85a9c3a8f3 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, @@ -6202,6 +6207,7 @@ impl ChatWidget { self.on_request_user_input(params); } ServerRequest::DynamicToolCall { .. } + | ServerRequest::AttestationGenerate { .. } | ServerRequest::ChatgptAuthTokensRefresh { .. } | ServerRequest::ApplyPatchApproval { .. } | ServerRequest::ExecCommandApproval { .. } => { @@ -7148,12 +7154,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 { 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) } 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}" + )); } }