From 9b8a1fbefcd507a5c7550b9c64e70f111094f195 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 24 Apr 2026 15:29:37 -0700 Subject: [PATCH 01/67] ci: publish codex-app-server release artifacts (#19447) ## Why The VS Code extension and desktop app do not need the full TUI binary, and `codex-app-server` is materially smaller than standalone `codex`. We still want to publish it as an official release artifact, but building it by tacking another `--bin` onto the existing release `cargo build` invocations would lengthen those jobs. This change keeps `codex-app-server` on its own release bundle so it can build in parallel with the existing `codex` and helper bundles. ## What changed - Made `.github/workflows/rust-release.yml` bundle-aware so each macOS and Linux MUSL target now builds either the existing `primary` bundle (`codex` and `codex-responses-api-proxy`) or a standalone `app-server` bundle (`codex-app-server`). - Preserved the historical artifact names for the primary macOS/Linux bundles so `scripts/stage_npm_packages.py` and `codex-cli/scripts/install_native_deps.py` continue to find release assets under the paths they already expect, while giving the new app-server artifacts distinct names. - Added a matching `app-server` bundle to `.github/workflows/rust-release-windows.yml`, and updated the final Windows packaging job to download, sign, stage, and archive `codex-app-server.exe` alongside the existing release binaries. - Generalized the shared signing actions in `.github/actions/linux-code-sign/action.yml`, `.github/actions/macos-code-sign/action.yml`, and `.github/actions/windows-code-sign/action.yml` so each workflow row declares its binaries once and reuses that list for build, signing, and staging. - Added `codex-app-server` to `.github/dotslash-config.json` so releases also publish a generated DotSlash manifest for the standalone app-server binary. - Kept the macOS DMG focused on the existing `primary` bundle; `codex-app-server` ships as the regular standalone archives and DotSlash manifest. ## Verification - Parsed the modified workflow and action YAML files locally with `python3` + `yaml.safe_load(...)`. - Parsed `.github/dotslash-config.json` locally with `python3` + `json.loads(...)`. - Reviewed the resulting release matrices, artifact names, and packaging paths to confirm that `codex-app-server` is built separately on macOS, Linux MUSL, and Windows, while the existing npm staging and Windows `codex` zip bundling contracts remain intact. --- .github/actions/linux-code-sign/action.yml | 6 +- .github/actions/macos-code-sign/action.yml | 12 ++- .github/actions/windows-code-sign/action.yml | 26 +++++- .github/dotslash-config.json | 28 ++++++ .github/workflows/rust-release-windows.yml | 62 +++++++++---- .github/workflows/rust-release.yml | 96 ++++++++++++++------ 6 files changed, 173 insertions(+), 57 deletions(-) diff --git a/.github/actions/linux-code-sign/action.yml b/.github/actions/linux-code-sign/action.yml index 12e521187f..f8efb822f8 100644 --- a/.github/actions/linux-code-sign/action.yml +++ b/.github/actions/linux-code-sign/action.yml @@ -7,6 +7,9 @@ inputs: artifacts-dir: description: Absolute path to the directory containing built binaries to sign. required: true + binaries: + description: Space-delimited binary basenames to sign. + default: "codex codex-responses-api-proxy" runs: using: composite @@ -18,6 +21,7 @@ runs: shell: bash env: ARTIFACTS_DIR: ${{ inputs.artifacts-dir }} + BINARIES: ${{ inputs.binaries }} COSIGN_EXPERIMENTAL: "1" COSIGN_YES: "true" COSIGN_OIDC_CLIENT_ID: "sigstore" @@ -31,7 +35,7 @@ runs: exit 1 fi - for binary in codex codex-responses-api-proxy; do + for binary in ${BINARIES}; do artifact="${dest}/${binary}" if [[ ! -f "$artifact" ]]; then echo "Binary $artifact not found" diff --git a/.github/actions/macos-code-sign/action.yml b/.github/actions/macos-code-sign/action.yml index 200b23901f..0e19fa11d0 100644 --- a/.github/actions/macos-code-sign/action.yml +++ b/.github/actions/macos-code-sign/action.yml @@ -4,6 +4,9 @@ inputs: target: description: Rust compilation target triple (e.g. aarch64-apple-darwin). required: true + binaries: + description: Space-delimited binary basenames to sign and notarize. + default: "codex codex-responses-api-proxy" sign-binaries: description: Whether to sign and notarize the macOS binaries. required: false @@ -119,6 +122,7 @@ runs: shell: bash env: TARGET: ${{ inputs.target }} + BINARIES: ${{ inputs.binaries }} run: | set -euo pipefail @@ -134,7 +138,7 @@ runs: entitlements_path="$GITHUB_ACTION_PATH/codex.entitlements.plist" - for binary in codex codex-responses-api-proxy; do + for binary in ${BINARIES}; do path="codex-rs/target/${TARGET}/release/${binary}" codesign --force --options runtime --timestamp --entitlements "$entitlements_path" --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path" done @@ -144,6 +148,7 @@ runs: shell: bash env: TARGET: ${{ inputs.target }} + BINARIES: ${{ inputs.binaries }} APPLE_NOTARIZATION_KEY_P8: ${{ inputs.apple-notarization-key-p8 }} APPLE_NOTARIZATION_KEY_ID: ${{ inputs.apple-notarization-key-id }} APPLE_NOTARIZATION_ISSUER_ID: ${{ inputs.apple-notarization-issuer-id }} @@ -182,8 +187,9 @@ runs: notarize_submission "$binary" "$archive_path" "$notary_key_path" } - notarize_binary "codex" - notarize_binary "codex-responses-api-proxy" + for binary in ${BINARIES}; do + notarize_binary "${binary}" + done - name: Sign and notarize macOS dmg if: ${{ inputs.sign-dmg == 'true' }} diff --git a/.github/actions/windows-code-sign/action.yml b/.github/actions/windows-code-sign/action.yml index b79c790f16..6289fa917d 100644 --- a/.github/actions/windows-code-sign/action.yml +++ b/.github/actions/windows-code-sign/action.yml @@ -4,6 +4,9 @@ inputs: target: description: Target triple for the artifacts to sign. required: true + binaries: + description: Space-delimited binary basenames to sign. + default: "codex codex-responses-api-proxy codex-windows-sandbox-setup codex-command-runner" client-id: description: Azure Trusted Signing client ID. required: true @@ -33,6 +36,23 @@ runs: tenant-id: ${{ inputs.tenant-id }} subscription-id: ${{ inputs.subscription-id }} + - name: Prepare file list + id: prepare + shell: bash + env: + TARGET: ${{ inputs.target }} + BINARIES: ${{ inputs.binaries }} + run: | + set -euo pipefail + + { + echo "files<> "$GITHUB_OUTPUT" + - name: Sign Windows binaries with Azure Trusted Signing uses: azure/trusted-signing-action@1d365fec12862c4aa68fcac418143d73f0cea293 # v0 with: @@ -50,8 +70,4 @@ runs: exclude-azure-developer-cli-credential: true exclude-interactive-browser-credential: true cache-dependencies: false - files: | - ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex.exe - ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-responses-api-proxy.exe - ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-windows-sandbox-setup.exe - ${{ github.workspace }}/codex-rs/target/${{ inputs.target }}/release/codex-command-runner.exe + files: ${{ steps.prepare.outputs.files }} diff --git a/.github/dotslash-config.json b/.github/dotslash-config.json index 00e9032cf1..5caef01e85 100644 --- a/.github/dotslash-config.json +++ b/.github/dotslash-config.json @@ -28,6 +28,34 @@ } } }, + "codex-app-server": { + "platforms": { + "macos-aarch64": { + "regex": "^codex-app-server-aarch64-apple-darwin\\.zst$", + "path": "codex-app-server" + }, + "macos-x86_64": { + "regex": "^codex-app-server-x86_64-apple-darwin\\.zst$", + "path": "codex-app-server" + }, + "linux-x86_64": { + "regex": "^codex-app-server-x86_64-unknown-linux-musl\\.zst$", + "path": "codex-app-server" + }, + "linux-aarch64": { + "regex": "^codex-app-server-aarch64-unknown-linux-musl\\.zst$", + "path": "codex-app-server" + }, + "windows-x86_64": { + "regex": "^codex-app-server-x86_64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-app-server.exe" + }, + "windows-aarch64": { + "regex": "^codex-app-server-aarch64-pc-windows-msvc\\.exe\\.zst$", + "path": "codex-app-server.exe" + } + } + }, "codex-responses-api-proxy": { "platforms": { "macos-aarch64": { diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index f1aee51911..7a4843243a 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -40,28 +40,42 @@ jobs: - runner: windows-x64 target: x86_64-pc-windows-msvc bundle: primary - build_args: --bin codex --bin codex-responses-api-proxy + binaries: "codex codex-responses-api-proxy" runs_on: group: codex-runners labels: codex-windows-x64 - runner: windows-arm64 target: aarch64-pc-windows-msvc bundle: primary - build_args: --bin codex --bin codex-responses-api-proxy + binaries: "codex codex-responses-api-proxy" runs_on: group: codex-runners labels: codex-windows-arm64 - runner: windows-x64 target: x86_64-pc-windows-msvc bundle: helpers - build_args: --bin codex-windows-sandbox-setup --bin codex-command-runner + binaries: "codex-windows-sandbox-setup codex-command-runner" runs_on: group: codex-runners labels: codex-windows-x64 - runner: windows-arm64 target: aarch64-pc-windows-msvc bundle: helpers - build_args: --bin codex-windows-sandbox-setup --bin codex-command-runner + binaries: "codex-windows-sandbox-setup codex-command-runner" + runs_on: + group: codex-runners + labels: codex-windows-arm64 + - runner: windows-x64 + target: x86_64-pc-windows-msvc + bundle: app-server + binaries: "codex-app-server" + runs_on: + group: codex-runners + labels: codex-windows-x64 + - runner: windows-arm64 + target: aarch64-pc-windows-msvc + bundle: app-server + binaries: "codex-app-server" runs_on: group: codex-runners labels: codex-windows-arm64 @@ -89,7 +103,11 @@ jobs: - name: Cargo build (Windows binaries) shell: bash run: | - cargo build --target ${{ matrix.target }} --release --timings ${{ matrix.build_args }} + build_args=() + for binary in ${{ matrix.binaries }}; do + build_args+=(--bin "$binary") + done + cargo build --target ${{ matrix.target }} --release --timings "${build_args[@]}" - name: Upload Cargo timings uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 @@ -103,13 +121,9 @@ jobs: run: | output_dir="target/${{ matrix.target }}/release/staged-${{ matrix.bundle }}" mkdir -p "$output_dir" - if [[ "${{ matrix.bundle }}" == "primary" ]]; then - cp target/${{ matrix.target }}/release/codex.exe "$output_dir/codex.exe" - cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$output_dir/codex-responses-api-proxy.exe" - else - cp target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe "$output_dir/codex-windows-sandbox-setup.exe" - cp target/${{ matrix.target }}/release/codex-command-runner.exe "$output_dir/codex-command-runner.exe" - fi + for binary in ${{ matrix.binaries }}; do + cp "target/${{ matrix.target }}/release/${binary}.exe" "$output_dir/${binary}.exe" + done - name: Upload Windows binaries uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 @@ -130,6 +144,8 @@ jobs: defaults: run: working-directory: codex-rs + env: + WINDOWS_BINARIES: "codex codex-responses-api-proxy codex-windows-sandbox-setup codex-command-runner codex-app-server" strategy: fail-fast: false @@ -161,19 +177,25 @@ jobs: name: windows-binaries-${{ matrix.target }}-helpers path: codex-rs/target/${{ matrix.target }}/release + - name: Download prebuilt Windows app-server binary + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + name: windows-binaries-${{ matrix.target }}-app-server + path: codex-rs/target/${{ matrix.target }}/release + - name: Verify binaries shell: bash run: | set -euo pipefail - ls -lh target/${{ matrix.target }}/release/codex.exe - ls -lh target/${{ matrix.target }}/release/codex-responses-api-proxy.exe - ls -lh target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe - ls -lh target/${{ matrix.target }}/release/codex-command-runner.exe + for binary in ${WINDOWS_BINARIES}; do + ls -lh "target/${{ matrix.target }}/release/${binary}.exe" + done - name: Sign Windows binaries with Azure Trusted Signing uses: ./.github/actions/windows-code-sign with: target: ${{ matrix.target }} + binaries: ${{ env.WINDOWS_BINARIES }} client-id: ${{ secrets.AZURE_TRUSTED_SIGNING_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TRUSTED_SIGNING_TENANT_ID }} subscription-id: ${{ secrets.AZURE_TRUSTED_SIGNING_SUBSCRIPTION_ID }} @@ -187,10 +209,10 @@ jobs: dest="dist/${{ matrix.target }}" mkdir -p "$dest" - cp target/${{ matrix.target }}/release/codex.exe "$dest/codex-${{ matrix.target }}.exe" - cp target/${{ matrix.target }}/release/codex-responses-api-proxy.exe "$dest/codex-responses-api-proxy-${{ matrix.target }}.exe" - cp target/${{ matrix.target }}/release/codex-windows-sandbox-setup.exe "$dest/codex-windows-sandbox-setup-${{ matrix.target }}.exe" - cp target/${{ matrix.target }}/release/codex-command-runner.exe "$dest/codex-command-runner-${{ matrix.target }}.exe" + for binary in ${WINDOWS_BINARIES}; do + cp "target/${{ matrix.target }}/release/${binary}.exe" \ + "$dest/${binary}-${{ matrix.target }}.exe" + done - name: Install DotSlash uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index ab0bc6e184..305082ef13 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -47,7 +47,7 @@ jobs: build: needs: tag-check - name: Build - ${{ matrix.runner }} - ${{ matrix.target }} + name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} runs-on: ${{ matrix.runs_on || matrix.runner }} timeout-minutes: 60 permissions: @@ -67,13 +67,53 @@ jobs: include: - runner: macos-15-xlarge target: aarch64-apple-darwin + bundle: primary + artifact_name: aarch64-apple-darwin + binaries: "codex codex-responses-api-proxy" + build_dmg: "true" + - runner: macos-15-xlarge + target: aarch64-apple-darwin + bundle: app-server + artifact_name: aarch64-apple-darwin-app-server + binaries: "codex-app-server" + build_dmg: "false" - runner: macos-15-xlarge target: x86_64-apple-darwin + bundle: primary + artifact_name: x86_64-apple-darwin + binaries: "codex codex-responses-api-proxy" + build_dmg: "true" + - runner: macos-15-xlarge + target: x86_64-apple-darwin + bundle: app-server + artifact_name: x86_64-apple-darwin-app-server + binaries: "codex-app-server" + build_dmg: "false" # Release artifacts intentionally ship MUSL-linked Linux binaries. - runner: ubuntu-24.04 target: x86_64-unknown-linux-musl + bundle: primary + artifact_name: x86_64-unknown-linux-musl + binaries: "codex codex-responses-api-proxy" + build_dmg: "false" + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-musl + bundle: app-server + artifact_name: x86_64-unknown-linux-musl-app-server + binaries: "codex-app-server" + build_dmg: "false" - runner: ubuntu-24.04-arm target: aarch64-unknown-linux-musl + bundle: primary + artifact_name: aarch64-unknown-linux-musl + binaries: "codex codex-responses-api-proxy" + build_dmg: "false" + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + bundle: app-server + artifact_name: aarch64-unknown-linux-musl-app-server + binaries: "codex-app-server" + build_dmg: "false" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 @@ -216,13 +256,17 @@ jobs: - name: Cargo build shell: bash run: | + build_args=() + for binary in ${{ matrix.binaries }}; do + build_args+=(--bin "$binary") + done echo "CARGO_PROFILE_RELEASE_LTO: ${CARGO_PROFILE_RELEASE_LTO}" - cargo build --target ${{ matrix.target }} --release --timings --bin codex --bin codex-responses-api-proxy + cargo build --target ${{ matrix.target }} --release --timings "${build_args[@]}" - name: Upload Cargo timings uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: - name: cargo-timings-rust-release-${{ matrix.target }} + name: cargo-timings-rust-release-${{ matrix.target }}-${{ matrix.bundle }} path: codex-rs/target/**/cargo-timings/cargo-timing.html if-no-files-found: warn @@ -232,12 +276,14 @@ jobs: with: target: ${{ matrix.target }} artifacts-dir: ${{ github.workspace }}/codex-rs/target/${{ matrix.target }}/release + binaries: ${{ matrix.binaries }} - if: ${{ runner.os == 'macOS' }} name: MacOS code signing (binaries) uses: ./.github/actions/macos-code-sign with: target: ${{ matrix.target }} + binaries: ${{ matrix.binaries }} sign-binaries: "true" sign-dmg: "false" apple-certificate: ${{ secrets.APPLE_CERTIFICATE_P12 }} @@ -246,7 +292,7 @@ jobs: apple-notarization-key-id: ${{ secrets.APPLE_NOTARIZATION_KEY_ID }} apple-notarization-issuer-id: ${{ secrets.APPLE_NOTARIZATION_ISSUER_ID }} - - if: ${{ runner.os == 'macOS' }} + - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' }} name: Build macOS dmg shell: bash run: | @@ -261,23 +307,17 @@ jobs: # The previous "MacOS code signing (binaries)" step signs + notarizes the # built artifacts in `${release_dir}`. This step packages *those same* # signed binaries into a dmg. - codex_binary_path="${release_dir}/codex" - proxy_binary_path="${release_dir}/codex-responses-api-proxy" - rm -rf "$dmg_root" mkdir -p "$dmg_root" - if [[ ! -f "$codex_binary_path" ]]; then - echo "Binary $codex_binary_path not found" - exit 1 - fi - if [[ ! -f "$proxy_binary_path" ]]; then - echo "Binary $proxy_binary_path not found" - exit 1 - fi - - ditto "$codex_binary_path" "${dmg_root}/codex" - ditto "$proxy_binary_path" "${dmg_root}/codex-responses-api-proxy" + for binary in ${{ matrix.binaries }}; do + binary_path="${release_dir}/${binary}" + if [[ ! -f "${binary_path}" ]]; then + echo "Binary ${binary_path} not found" + exit 1 + fi + ditto "${binary_path}" "${dmg_root}/${binary}" + done rm -f "$dmg_path" hdiutil create \ @@ -292,7 +332,7 @@ jobs: exit 1 fi - - if: ${{ runner.os == 'macOS' }} + - if: ${{ runner.os == 'macOS' && matrix.build_dmg == 'true' }} name: MacOS code signing (dmg) uses: ./.github/actions/macos-code-sign with: @@ -311,15 +351,15 @@ jobs: dest="dist/${{ matrix.target }}" mkdir -p "$dest" - cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}" - cp target/${{ matrix.target }}/release/codex-responses-api-proxy "$dest/codex-responses-api-proxy-${{ matrix.target }}" + for binary in ${{ matrix.binaries }}; do + cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}" + if [[ "${{ matrix.target }}" == *linux* ]]; then + cp "target/${{ matrix.target }}/release/${binary}.sigstore" \ + "$dest/${binary}-${{ matrix.target }}.sigstore" + fi + done - if [[ "${{ matrix.target }}" == *linux* ]]; then - cp target/${{ matrix.target }}/release/codex.sigstore "$dest/codex-${{ matrix.target }}.sigstore" - cp target/${{ matrix.target }}/release/codex-responses-api-proxy.sigstore "$dest/codex-responses-api-proxy-${{ matrix.target }}.sigstore" - fi - - if [[ "${{ matrix.target }}" == *apple-darwin ]]; then + if [[ "${{ matrix.build_dmg }}" == "true" ]]; then cp target/${{ matrix.target }}/release/codex-${{ matrix.target }}.dmg "$dest/codex-${{ matrix.target }}.dmg" fi @@ -361,7 +401,7 @@ jobs: - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7 with: - name: ${{ matrix.target }} + name: ${{ matrix.artifact_name }} # Upload the per-binary .zst files as well as the new .tar.gz # equivalents we generated in the previous step. path: | From 3c6e2638ac9c324c0244e20006f298a53a9939be Mon Sep 17 00:00:00 2001 From: Konstantine Kahadze Date: Fri, 24 Apr 2026 15:35:52 -0700 Subject: [PATCH 02/67] Clarify bundled OpenAI Docs upgrade guide wording (#19422) ## Summary - Mirrors the OpenAI Docs skill cleanup in the bundled Codex skill copy - Clarifies reasoning-effort recommendation wording - Replaces internal snake_case prompt block names with natural-language guidance aligned to the prompting guide ## Test plan - `git diff --check` - Verified the old snake_case prompt block names no longer appear in the bundled upgrade guide --- .../openai-docs/references/upgrade-guide.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/codex-rs/skills/src/assets/samples/openai-docs/references/upgrade-guide.md b/codex-rs/skills/src/assets/samples/openai-docs/references/upgrade-guide.md index 07b90c655e..b29f137bc6 100644 --- a/codex-rs/skills/src/assets/samples/openai-docs/references/upgrade-guide.md +++ b/codex-rs/skills/src/assets/samples/openai-docs/references/upgrade-guide.md @@ -60,9 +60,9 @@ Upgrade with the narrowest safe change set: Output rule: -- Always emit a starting `reasoning_effort_recommendation` for each usage site. -- If the repo exposes the current reasoning setting, preserve it first unless current OpenAI docs say otherwise. -- If the repo does not expose the current setting, do not add one unless current OpenAI docs require it. +- For each usage site, state the starting reasoning-effort recommendation. +- If the repo exposes the current reasoning setting, recommend preserving it first unless current OpenAI docs say otherwise. +- If the repo does not expose the current setting, recommend not adding one unless current OpenAI docs require it. ## Upgrade outcomes @@ -99,10 +99,10 @@ Default action: - make only the smallest prompt edits needed for the observed workflow risk - read the [GPT-5.5 prompting guide](/api/docs/guides/prompt-guidance?model=gpt-5.5) to choose the smallest prompt changes that recover or improve behavior - avoid broad prompt cleanup unrelated to the upgrade -- for research workflows, default to `research_mode` + `citation_rules` + `empty_result_handling`; add `tool_persistence_rules` when the host already uses retrieval tools -- for dependency-aware or tool-heavy workflows, default to `tool_persistence_rules` + `dependency_checks` + `verification_loop`; add `parallel_tool_calling` only when retrieval steps are truly independent -- for coding or terminal workflows, default to `terminal_tool_hygiene` + `verification_loop` -- for multi-agent support or triage workflows, default to at least one of `tool_persistence_rules`, `completeness_contract`, or `verification_loop` +- for research workflows, add citation rules, retrieval budgets, missing-evidence behavior, and validation guidance from the prompting guide +- for dependency-aware or tool-heavy workflows, add prerequisite checks, missing-context handling, explicit tool budgets, stop conditions, and validation guidance +- for coding or terminal workflows, add repo-specific constraints, acceptance criteria, and concrete validation commands +- for multi-agent support or triage workflows, add task ownership, handoff, completeness, and stopping criteria - for long-running Responses agents with preambles or multiple assistant messages, explicitly review whether `phase` is already handled; if adding or preserving `phase` would require code edits, mark the path as `blocked` - do not classify a coding or tool-using Responses workflow as `blocked` just because the visible snippet is minimal; prefer `model string + light prompt rewrite` unless the repo clearly shows that a safe GPT-5.5 path would require host-side code changes From 588f7a9fc4c0e8b2c1d80d94930fec159e77c27b Mon Sep 17 00:00:00 2001 From: Tom Date: Fri, 24 Apr 2026 15:45:44 -0700 Subject: [PATCH 03/67] [codex] add non-local thread store regression harness (#19266) - Add an integration test that guarantees nothing gets written to codex home dir or sqlite when running a rollout with a non-local ThreadStore - Add an in-memory "spy" ThreadStore for tests like this Note I could not find a good way to also ensure there were no filesystem _reads_ that didn't go through threadstore. I explored a more elaborate sandboxed-subprocess approach but it isn't platform portable and felt like it wasn't (yet) worth it. --- .../app-server/src/codex_message_processor.rs | 11 +- codex-rs/app-server/tests/suite/v2/mod.rs | 2 + .../tests/suite/v2/remote_thread_store.rs | 254 ++++++++++++++++ codex-rs/config/src/config_toml.rs | 17 ++ codex-rs/core/config.schema.json | 44 +++ codex-rs/core/src/config/config_tests.rs | 9 +- codex-rs/core/src/config/mod.rs | 41 ++- codex-rs/core/src/thread_manager.rs | 13 +- codex-rs/thread-store/src/in_memory.rs | 285 ++++++++++++++++++ codex-rs/thread-store/src/lib.rs | 6 + 10 files changed, 667 insertions(+), 15 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/v2/remote_thread_store.rs create mode 100644 codex-rs/thread-store/src/in_memory.rs diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 981b52a598..bf6b4bdf93 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -235,6 +235,7 @@ use codex_core::clear_memory_roots_contents; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::NetworkProxyAuditMetadata; +use codex_core::config::ThreadStoreConfig; use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config_loader::CloudRequirementsLoadError; @@ -353,6 +354,8 @@ use codex_state::ThreadMetadata; use codex_state::ThreadMetadataBuilder; use codex_state::log_db::LogDbLayer; use codex_thread_store::ArchiveThreadParams as StoreArchiveThreadParams; +#[cfg(debug_assertions)] +use codex_thread_store::InMemoryThreadStore; use codex_thread_store::ListThreadsParams as StoreListThreadsParams; use codex_thread_store::LocalThreadStore; use codex_thread_store::ReadThreadByRolloutPathParams as StoreReadThreadByRolloutPathParams; @@ -661,9 +664,11 @@ pub(crate) struct CodexMessageProcessorArgs { } fn configured_thread_store(config: &Config) -> Arc { - match config.experimental_thread_store_endpoint.as_deref() { - Some(endpoint) => Arc::new(RemoteThreadStore::new(endpoint)), - None => Arc::new(configured_local_thread_store(config)), + match &config.experimental_thread_store { + ThreadStoreConfig::Local => Arc::new(configured_local_thread_store(config)), + ThreadStoreConfig::Remote { endpoint } => Arc::new(RemoteThreadStore::new(endpoint)), + #[cfg(debug_assertions)] + ThreadStoreConfig::InMemory { id } => InMemoryThreadStore::for_id(id), } } diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 4a3f231836..776424cc99 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -34,6 +34,8 @@ mod plugin_read; mod plugin_uninstall; mod rate_limits; mod realtime_conversation; +#[cfg(debug_assertions)] +mod remote_thread_store; mod request_permissions; mod request_user_input; mod review; diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs new file mode 100644 index 0000000000..ebee1fd7c1 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -0,0 +1,254 @@ +//! Regression coverage for app-server thread operations backed by a non-local +//! `ThreadStore`. +//! +//! The app-server startup path should honor `experimental_thread_store` +//! by routing all thread persistence through the configured store. This suite uses +//! the thread-store crate's test-only in-memory store, which exercises the same +//! config-driven selection path as a remote store without requiring the real gRPC +//! service. +//! +//! The important failure mode is accidentally materializing local persistence +//! while a non-local store is configured. After `thread/start` and a simple turn, +//! the temporary `codex_home` must not contain rollout session files or sqlite +//! state files. This does not observe read-only probes that leave no artifact; it +//! is a stop-gap that prevents additional local persistence writes from slipping +//! in unnoticed. + +use std::collections::BTreeSet; +use std::path::Path; +use std::sync::Arc; + +use anyhow::Result; +use app_test_support::create_mock_responses_server_repeating_assistant; +use codex_app_server::in_process; +use codex_app_server::in_process::InProcessServerEvent; +use codex_app_server::in_process::InProcessStartArgs; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::InitializeParams; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_arg0::Arg0DispatchPaths; +use codex_config::NoopThreadConfigLoader; +use codex_core::config::ConfigBuilder; +use codex_core::config_loader::CloudRequirementsLoader; +use codex_core::config_loader::LoaderOverrides; +use codex_exec_server::EnvironmentManager; +use codex_feedback::CodexFeedback; +use codex_protocol::protocol::SessionSource; +use codex_thread_store::InMemoryThreadStore; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; +use uuid::Uuid; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn thread_start_with_non_local_thread_store_does_not_create_local_persistence() -> Result<()> +{ + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + let store_id = Uuid::new_v4().to_string(); + create_config_toml_with_thread_store(codex_home.path(), &server.uri(), &store_id)?; + + let loader_overrides = LoaderOverrides::without_managed_config_for_tests(); + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .loader_overrides(loader_overrides.clone()) + .build() + .await?; + + let thread_store = InMemoryThreadStore::for_id(store_id.clone()); + let _in_memory_store = InMemoryThreadStoreId { store_id }; + + let mut client = in_process::start(InProcessStartArgs { + arg0_paths: Arg0DispatchPaths::default(), + config: Arc::new(config), + cli_overrides: Vec::new(), + loader_overrides, + cloud_requirements: CloudRequirementsLoader::default(), + thread_config_loader: Arc::new(NoopThreadConfigLoader), + feedback: CodexFeedback::new(), + log_db: None, + environment_manager: Arc::new(EnvironmentManager::default_for_tests()), + config_warnings: Vec::new(), + session_source: SessionSource::Cli, + enable_codex_api_key_env: false, + initialize: InitializeParams { + client_info: ClientInfo { + name: "codex-app-server-tests".to_string(), + title: None, + version: "0.1.0".to_string(), + }, + capabilities: None, + }, + channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await?; + + let response = client + .request(ClientRequest::ThreadStart { + request_id: RequestId::Integer(1), + params: ThreadStartParams::default(), + }) + .await? + .expect("thread/start should succeed"); + let ThreadStartResponse { thread, .. } = + serde_json::from_value(response).expect("thread/start response should parse"); + assert_eq!(thread.path, None); + + client + .request(ClientRequest::TurnStart { + request_id: RequestId::Integer(2), + params: TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }, + }) + .await? + .expect("turn/start should succeed"); + + timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let Some(event) = client.next_event().await else { + anyhow::bail!("in-process app-server stopped before turn/completed"); + }; + if let InProcessServerEvent::ServerNotification(ServerNotification::TurnCompleted( + completed, + )) = event + && completed.thread_id == thread.id + { + return Ok::<(), anyhow::Error>(()); + } + } + }) + .await??; + + client.shutdown().await?; + + let calls = thread_store.calls().await; + assert_eq!(calls.create_thread, 1); + assert!( + calls.append_items > 0, + "turn/start should append rollout items through the injected store" + ); + assert!( + calls.flush_thread > 0, + "turn completion should flush through the injected store" + ); + + assert_no_local_persistence_artifacts(codex_home.path())?; + + Ok(()) +} + +fn assert_no_local_persistence_artifacts(codex_home: &Path) -> Result<()> { + // These are the observable tripwires for accidental local persistence. If a + // future code path constructs a local rollout/session store or opens the + // local thread sqlite database, it should leave one of these artifacts in + // the isolated test codex_home. + assert!( + !codex_home.join("sessions").exists(), + "non-local thread persistence should not create local rollout sessions" + ); + assert!( + !codex_home.join("archived_sessions").exists(), + "non-local thread persistence should not create archived rollout sessions" + ); + assert!( + !codex_state::state_db_path(codex_home).exists(), + "non-local thread persistence should not create local thread sqlite" + ); + + let sqlite_artifacts = std::fs::read_dir(codex_home)? + .filter_map(std::result::Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| { + name.ends_with(".sqlite") + || name.ends_with(".sqlite-shm") + || name.ends_with(".sqlite-wal") + }) + }) + .collect::>(); + + assert!( + sqlite_artifacts.is_empty(), + "non-local thread persistence should not create sqlite artifacts: {sqlite_artifacts:?}" + ); + let mut entries = codex_home_entries(codex_home)?; + // Bazel test runs may initialize shell snapshot storage under codex_home. + // That is not thread persistence; keep the assertion focused on rollout, + // session, sqlite, and other unexpected thread-store artifacts. + entries.remove("shell_snapshots"); + assert_eq!( + entries, + BTreeSet::from([ + "config.toml".to_string(), + "installation_id".to_string(), + "memories".to_string(), + "skills".to_string(), + ]), + "non-local thread persistence should not create unexpected files in codex_home" + ); + + Ok(()) +} + +fn codex_home_entries(codex_home: &Path) -> Result> { + Ok(std::fs::read_dir(codex_home)? + .filter_map(|entry| { + let entry = entry.ok()?; + Some(entry.file_name().to_string_lossy().into_owned()) + }) + .collect()) +} + +struct InMemoryThreadStoreId { + store_id: String, +} + +impl Drop for InMemoryThreadStoreId { + fn drop(&mut self) { + InMemoryThreadStore::remove_id(&self.store_id); + } +} + +fn create_config_toml_with_thread_store( + codex_home: &Path, + server_uri: &str, + store_id: &str, +) -> std::io::Result<()> { + std::fs::write( + codex_home.join("config.toml"), + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +experimental_thread_store = {{ type = "in_memory", id = "{store_id}" }} + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 9ee7841173..f0de00192f 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -317,6 +317,9 @@ pub struct ConfigToml { /// Experimental / do not use. When set, app-server fetches thread-scoped /// config from a remote service at this endpoint. pub experimental_thread_config_endpoint: Option, + + /// Experimental / do not use. Selects the thread store implementation. + pub experimental_thread_store: Option, pub projects: Option>, /// Controls the web search tool mode: disabled, cached, or live. @@ -413,6 +416,20 @@ pub struct ConfigToml { pub oss_provider: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ThreadStoreToml { + Local {}, + Remote { + endpoint: String, + }, + #[cfg(debug_assertions)] + #[schemars(skip)] + InMemory { + id: String, + }, +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] pub struct AutoReviewToml { /// Additional policy instructions inserted into the guardian prompt. diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 6cac429083..030c36a8b6 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2093,6 +2093,42 @@ }, "type": "object" }, + "ThreadStoreToml": { + "oneOf": [ + { + "properties": { + "type": { + "enum": [ + "local" + ], + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "properties": { + "endpoint": { + "type": "string" + }, + "type": { + "enum": [ + "remote" + ], + "type": "string" + } + }, + "required": [ + "endpoint", + "type" + ], + "type": "object" + } + ] + }, "ToolSuggestConfig": { "additionalProperties": false, "properties": { @@ -2489,6 +2525,14 @@ "description": "Experimental / do not use. When set, app-server fetches thread-scoped config from a remote service at this endpoint.", "type": "string" }, + "experimental_thread_store": { + "allOf": [ + { + "$ref": "#/definitions/ThreadStoreToml" + } + ], + "description": "Experimental / do not use. Selects the thread store implementation." + }, "experimental_thread_store_endpoint": { "description": "Experimental / do not use. When set, app-server uses a remote thread store at this endpoint instead of the local filesystem/SQLite store.", "type": "string" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7af47fe5e4..2686173208 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1,5 +1,6 @@ use crate::agents_md::DEFAULT_AGENTS_MD_FILENAME; use crate::agents_md::LOCAL_AGENTS_MD_FILENAME; +use crate::config::ThreadStoreConfig; use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::config::edit::apply_blocking; @@ -5294,8 +5295,8 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { realtime: RealtimeConfig::default(), experimental_realtime_ws_backend_prompt: None, experimental_realtime_ws_startup_context: None, - experimental_thread_store_endpoint: None, experimental_thread_config_endpoint: None, + experimental_thread_store: ThreadStoreConfig::Local, base_instructions: None, developer_instructions: None, guardian_policy_config: None, @@ -5492,8 +5493,8 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { realtime: RealtimeConfig::default(), experimental_realtime_ws_backend_prompt: None, experimental_realtime_ws_startup_context: None, - experimental_thread_store_endpoint: None, experimental_thread_config_endpoint: None, + experimental_thread_store: ThreadStoreConfig::Local, base_instructions: None, developer_instructions: None, guardian_policy_config: None, @@ -5644,8 +5645,8 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { realtime: RealtimeConfig::default(), experimental_realtime_ws_backend_prompt: None, experimental_realtime_ws_startup_context: None, - experimental_thread_store_endpoint: None, experimental_thread_config_endpoint: None, + experimental_thread_store: ThreadStoreConfig::Local, base_instructions: None, developer_instructions: None, guardian_policy_config: None, @@ -5781,8 +5782,8 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { realtime: RealtimeConfig::default(), experimental_realtime_ws_backend_prompt: None, experimental_realtime_ws_startup_context: None, - experimental_thread_store_endpoint: None, experimental_thread_config_endpoint: None, + experimental_thread_store: ThreadStoreConfig::Local, base_instructions: None, developer_instructions: None, guardian_policy_config: None, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 9bdbeb9d1c..33fe18d1f4 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -27,6 +27,7 @@ use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; use codex_config::config_toml::RealtimeAudioConfig; use codex_config::config_toml::RealtimeConfig; +use codex_config::config_toml::ThreadStoreToml; use codex_config::config_toml::validate_model_providers; use codex_config::profile_toml::ConfigProfile; use codex_config::types::ApprovalsReviewer; @@ -230,6 +231,19 @@ impl Permissions { } } +/// Configured thread persistence backend. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum ThreadStoreConfig { + /// Persist threads locally using rollout JSONL files and sqlite metadata. + #[default] + Local, + /// Persist threads through the remote thread-store service. + Remote { endpoint: String }, + /// Test-only in-memory thread store. + #[cfg(debug_assertions)] + InMemory { id: String }, +} + /// Application configuration loaded from disk and merged with overrides. #[derive(Debug, Clone, PartialEq)] pub struct Config { @@ -545,13 +559,12 @@ pub struct Config { /// active. pub experimental_realtime_start_instructions: Option, - /// Experimental / do not use. When set, app-server uses a remote thread - /// store at this endpoint instead of the local filesystem/SQLite store. - pub experimental_thread_store_endpoint: Option, - /// Experimental / do not use. When set, app-server fetches thread-scoped /// config from a remote service at this endpoint. pub experimental_thread_config_endpoint: Option, + + /// Experimental / do not use. Selects the thread persistence backend. + pub experimental_thread_store: ThreadStoreConfig, /// When set, restricts ChatGPT login to a specific workspace identifier. pub forced_chatgpt_workspace_id: Option, @@ -1297,6 +1310,21 @@ fn resolve_tool_suggest_config(config_toml: &ConfigToml) -> ToolSuggestConfig { ToolSuggestConfig { discoverables } } +fn thread_store_config( + thread_store: Option, + legacy_remote_endpoint: Option, +) -> ThreadStoreConfig { + match thread_store { + Some(ThreadStoreToml::Local {}) => ThreadStoreConfig::Local, + Some(ThreadStoreToml::Remote { endpoint }) => ThreadStoreConfig::Remote { endpoint }, + #[cfg(debug_assertions)] + Some(ThreadStoreToml::InMemory { id }) => ThreadStoreConfig::InMemory { id }, + None => legacy_remote_endpoint.map_or(ThreadStoreConfig::Local, |endpoint| { + ThreadStoreConfig::Remote { endpoint } + }), + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum PermissionConfigSyntax { Legacy, @@ -2434,8 +2462,11 @@ impl Config { experimental_realtime_ws_backend_prompt: cfg.experimental_realtime_ws_backend_prompt, experimental_realtime_ws_startup_context: cfg.experimental_realtime_ws_startup_context, experimental_realtime_start_instructions: cfg.experimental_realtime_start_instructions, - experimental_thread_store_endpoint: cfg.experimental_thread_store_endpoint, experimental_thread_config_endpoint: cfg.experimental_thread_config_endpoint, + experimental_thread_store: thread_store_config( + cfg.experimental_thread_store, + cfg.experimental_thread_store_endpoint, + ), forced_chatgpt_workspace_id, forced_login_method, include_apply_patch_tool: include_apply_patch_tool_flag, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 7ba595598e..30d220694d 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -2,6 +2,7 @@ use crate::SkillsManager; use crate::agent::AgentControl; 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::selected_primary_environment; use crate::environment_selection::validate_environment_selections; @@ -52,6 +53,8 @@ use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::protocol::W3cTraceContext; use codex_rollout::RolloutConfig; use codex_state::DirectionalThreadSpawnEdgeStatus; +#[cfg(debug_assertions)] +use codex_thread_store::InMemoryThreadStore; use codex_thread_store::LocalThreadStore; use codex_thread_store::RemoteThreadStore; use codex_thread_store::ThreadStore; @@ -251,10 +254,14 @@ pub fn build_models_manager( } fn configured_thread_store(config: &Config) -> Arc { - if let Some(endpoint) = config.experimental_thread_store_endpoint.as_deref() { - return Arc::new(RemoteThreadStore::new(endpoint)); + match &config.experimental_thread_store { + ThreadStoreConfig::Local => { + Arc::new(LocalThreadStore::new(RolloutConfig::from_view(config))) + } + ThreadStoreConfig::Remote { endpoint } => Arc::new(RemoteThreadStore::new(endpoint)), + #[cfg(debug_assertions)] + ThreadStoreConfig::InMemory { id } => InMemoryThreadStore::for_id(id), } - Arc::new(LocalThreadStore::new(RolloutConfig::from_view(config))) } impl ThreadManager { diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs new file mode 100644 index 0000000000..084975abd2 --- /dev/null +++ b/codex-rs/thread-store/src/in_memory.rs @@ -0,0 +1,285 @@ +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::Mutex; +use std::sync::MutexGuard; +use std::sync::OnceLock; + +use async_trait::async_trait; +use chrono::Utc; +use codex_protocol::ThreadId; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SandboxPolicy; + +use crate::AppendThreadItemsParams; +use crate::ArchiveThreadParams; +use crate::CreateThreadParams; +use crate::ListThreadsParams; +use crate::LoadThreadHistoryParams; +use crate::ReadThreadByRolloutPathParams; +use crate::ReadThreadParams; +use crate::ResumeThreadParams; +use crate::StoredThread; +use crate::StoredThreadHistory; +use crate::ThreadPage; +use crate::ThreadStore; +use crate::ThreadStoreError; +use crate::ThreadStoreResult; +use crate::UpdateThreadMetadataParams; + +static IN_MEMORY_THREAD_STORES: OnceLock>>> = + OnceLock::new(); + +fn stores() -> &'static Mutex>> { + IN_MEMORY_THREAD_STORES.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn stores_guard() -> MutexGuard<'static, HashMap>> { + match stores().lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + +/// Recorded call counts for [`InMemoryThreadStore`]. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct InMemoryThreadStoreCalls { + pub create_thread: usize, + pub resume_thread: usize, + pub append_items: usize, + pub persist_thread: usize, + pub flush_thread: usize, + pub shutdown_thread: usize, + pub discard_thread: usize, + pub load_history: usize, + pub read_thread: usize, + pub read_thread_by_rollout_path: usize, + pub list_threads: usize, + pub update_thread_metadata: usize, + pub archive_thread: usize, + pub unarchive_thread: usize, +} + +/// Test-only in-memory [`ThreadStore`] implementation. +/// +/// Debug/test configs can select this store by id, letting tests exercise +/// config-driven non-local persistence without requiring the real remote gRPC +/// service. +#[derive(Default)] +pub struct InMemoryThreadStore { + state: tokio::sync::Mutex, +} + +#[derive(Default)] +struct InMemoryThreadStoreState { + calls: InMemoryThreadStoreCalls, + created_threads: HashMap, + histories: HashMap>, + names: HashMap>, + rollout_paths: HashMap, +} + +impl InMemoryThreadStore { + /// Returns the store associated with `id`, creating it if needed. + pub fn for_id(id: impl Into) -> Arc { + let id = id.into(); + let mut stores = stores_guard(); + stores + .entry(id) + .or_insert_with(|| Arc::new(Self::default())) + .clone() + } + + /// Removes a shared in-memory store for `id`. + pub fn remove_id(id: &str) -> Option> { + stores_guard().remove(id) + } + + /// Returns the calls observed by this store. + pub async fn calls(&self) -> InMemoryThreadStoreCalls { + self.state.lock().await.calls.clone() + } +} + +#[async_trait] +impl ThreadStore for InMemoryThreadStore { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + async fn create_thread(&self, params: CreateThreadParams) -> ThreadStoreResult<()> { + let mut state = self.state.lock().await; + state.calls.create_thread += 1; + state.histories.entry(params.thread_id).or_default(); + state.created_threads.insert(params.thread_id, params); + Ok(()) + } + + async fn resume_thread(&self, params: ResumeThreadParams) -> ThreadStoreResult<()> { + let mut state = self.state.lock().await; + state.calls.resume_thread += 1; + state.histories.entry(params.thread_id).or_default(); + if let Some(rollout_path) = params.rollout_path { + state.rollout_paths.insert(rollout_path, params.thread_id); + } + Ok(()) + } + + async fn append_items(&self, params: AppendThreadItemsParams) -> ThreadStoreResult<()> { + let mut state = self.state.lock().await; + state.calls.append_items += 1; + state + .histories + .entry(params.thread_id) + .or_default() + .extend(params.items); + Ok(()) + } + + async fn persist_thread(&self, _thread_id: ThreadId) -> ThreadStoreResult<()> { + self.state.lock().await.calls.persist_thread += 1; + Ok(()) + } + + async fn flush_thread(&self, _thread_id: ThreadId) -> ThreadStoreResult<()> { + self.state.lock().await.calls.flush_thread += 1; + Ok(()) + } + + async fn shutdown_thread(&self, _thread_id: ThreadId) -> ThreadStoreResult<()> { + self.state.lock().await.calls.shutdown_thread += 1; + Ok(()) + } + + async fn discard_thread(&self, _thread_id: ThreadId) -> ThreadStoreResult<()> { + self.state.lock().await.calls.discard_thread += 1; + Ok(()) + } + + async fn load_history( + &self, + params: LoadThreadHistoryParams, + ) -> ThreadStoreResult { + let mut state = self.state.lock().await; + state.calls.load_history += 1; + let items = state.histories.get(¶ms.thread_id).cloned().ok_or( + ThreadStoreError::ThreadNotFound { + thread_id: params.thread_id, + }, + )?; + Ok(StoredThreadHistory { + thread_id: params.thread_id, + items, + }) + } + + async fn read_thread(&self, params: ReadThreadParams) -> ThreadStoreResult { + let mut state = self.state.lock().await; + state.calls.read_thread += 1; + stored_thread_from_state(&state, params.thread_id, params.include_history) + } + + async fn read_thread_by_rollout_path( + &self, + params: ReadThreadByRolloutPathParams, + ) -> ThreadStoreResult { + let mut state = self.state.lock().await; + state.calls.read_thread_by_rollout_path += 1; + let Some(thread_id) = state.rollout_paths.get(¶ms.rollout_path).copied() else { + return Err(ThreadStoreError::InvalidRequest { + message: format!( + "in-memory thread store does not know rollout path {}", + params.rollout_path.display() + ), + }); + }; + stored_thread_from_state(&state, thread_id, params.include_history) + } + + async fn list_threads(&self, _params: ListThreadsParams) -> ThreadStoreResult { + let mut state = self.state.lock().await; + state.calls.list_threads += 1; + let mut items = state + .created_threads + .keys() + .map(|thread_id| { + stored_thread_from_state(&state, *thread_id, /*include_history*/ false) + }) + .collect::>>()?; + items.sort_by_key(|item| item.thread_id.to_string()); + Ok(ThreadPage { + items, + next_cursor: None, + }) + } + + async fn update_thread_metadata( + &self, + params: UpdateThreadMetadataParams, + ) -> ThreadStoreResult { + let mut state = self.state.lock().await; + state.calls.update_thread_metadata += 1; + if let Some(name) = params.patch.name { + state.names.insert(params.thread_id, Some(name)); + } + stored_thread_from_state(&state, params.thread_id, /*include_history*/ false) + } + + async fn archive_thread(&self, _params: ArchiveThreadParams) -> ThreadStoreResult<()> { + self.state.lock().await.calls.archive_thread += 1; + Ok(()) + } + + async fn unarchive_thread( + &self, + params: ArchiveThreadParams, + ) -> ThreadStoreResult { + let mut state = self.state.lock().await; + state.calls.unarchive_thread += 1; + stored_thread_from_state(&state, params.thread_id, /*include_history*/ false) + } +} + +fn stored_thread_from_state( + state: &InMemoryThreadStoreState, + thread_id: ThreadId, + include_history: bool, +) -> ThreadStoreResult { + let created = state + .created_threads + .get(&thread_id) + .ok_or(ThreadStoreError::ThreadNotFound { thread_id })?; + let history_items = state.histories.get(&thread_id).cloned().unwrap_or_default(); + let history = include_history.then(|| StoredThreadHistory { + thread_id, + items: history_items.clone(), + }); + let name = state.names.get(&thread_id).cloned().flatten(); + + Ok(StoredThread { + thread_id, + rollout_path: None, + forked_from_id: created.forked_from_id, + preview: String::new(), + name, + model_provider: "test".to_string(), + model: None, + reasoning_effort: None, + created_at: Utc::now(), + updated_at: Utc::now(), + archived_at: None, + cwd: PathBuf::new(), + cli_version: "test".to_string(), + source: created.source.clone(), + agent_nickname: None, + agent_role: None, + agent_path: None, + git_info: None, + approval_mode: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + token_usage: None, + first_user_message: None, + history, + }) +} diff --git a/codex-rs/thread-store/src/lib.rs b/codex-rs/thread-store/src/lib.rs index c8a083e1ca..42b9297bca 100644 --- a/codex-rs/thread-store/src/lib.rs +++ b/codex-rs/thread-store/src/lib.rs @@ -5,6 +5,8 @@ //! any other backing store. mod error; +#[cfg(debug_assertions)] +mod in_memory; mod live_thread; mod local; mod remote; @@ -13,6 +15,10 @@ mod types; pub use error::ThreadStoreError; pub use error::ThreadStoreResult; +#[cfg(debug_assertions)] +pub use in_memory::InMemoryThreadStore; +#[cfg(debug_assertions)] +pub use in_memory::InMemoryThreadStoreCalls; pub use live_thread::LiveThread; pub use live_thread::LiveThreadInitGuard; pub use local::LocalThreadStore; From 1e560f33e101b19edffce18580fac8ccdc00b0cd Mon Sep 17 00:00:00 2001 From: xl-openai Date: Fri, 24 Apr 2026 15:49:07 -0700 Subject: [PATCH 04/67] feat: Compress skill paths with root aliases (#19098) Add skill root tracking so model-visible skill lists can use short path aliases when absolute paths would exceed the metadata budget. --- codex-rs/core-skills/src/lib.rs | 5 + codex-rs/core-skills/src/loader.rs | 16 +- codex-rs/core-skills/src/model.rs | 15 + codex-rs/core-skills/src/render.rs | 846 ++++++++++++++++-- .../context/available_skills_instructions.rs | 33 +- codex-rs/core/src/session/mod.rs | 6 +- codex-rs/core/src/session/tests.rs | 26 +- codex-rs/core/tests/suite/client.rs | 91 ++ 8 files changed, 935 insertions(+), 103 deletions(-) diff --git a/codex-rs/core-skills/src/lib.rs b/codex-rs/core-skills/src/lib.rs index 06ced0d5d4..eec3a5f054 100644 --- a/codex-rs/core-skills/src/lib.rs +++ b/codex-rs/core-skills/src/lib.rs @@ -23,7 +23,12 @@ pub use model::SkillMetadata; pub use model::SkillPolicy; pub use model::filter_skill_load_outcome_for_product; pub use render::AvailableSkills; +pub use render::SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS; +pub use render::SKILLS_HOW_TO_USE_WITH_ALIASES; +pub use render::SKILLS_INTRO_WITH_ABSOLUTE_PATHS; +pub use render::SKILLS_INTRO_WITH_ALIASES; pub use render::SkillMetadataBudget; pub use render::SkillRenderReport; pub use render::build_available_skills; pub use render::default_skill_metadata_budget; +pub use render::render_available_skills_body; diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index 2cae6a4b0b..d7a69e8a25 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -159,13 +159,22 @@ where I: IntoIterator, { let mut outcome = SkillLoadOutcome::default(); + let mut skill_roots: Vec = Vec::new(); + let mut skill_root_by_path: HashMap = HashMap::new(); let mut file_systems_by_skill_path: HashMap> = HashMap::new(); for root in roots { + let root_path = canonicalize_for_skill_identity(&root.path); let fs = root.file_system; let skills_before_root = outcome.skills.len(); - discover_skills_under_root(fs.as_ref(), &root.path, root.scope, &mut outcome).await; + discover_skills_under_root(fs.as_ref(), &root_path, root.scope, &mut outcome).await; for skill in &outcome.skills[skills_before_root..] { + if !skill_roots.contains(&root_path) { + skill_roots.push(root_path.clone()); + } + skill_root_by_path + .entry(skill.path_to_skills_md.clone()) + .or_insert_with(|| root_path.clone()); file_systems_by_skill_path .entry(skill.path_to_skills_md.clone()) .or_insert_with(|| Arc::clone(&fs)); @@ -181,7 +190,12 @@ where .iter() .map(|skill| skill.path_to_skills_md.clone()) .collect(); + skill_root_by_path.retain(|path, _| retained_skill_paths.contains(path)); + let used_roots: HashSet = skill_root_by_path.values().cloned().collect(); + skill_roots.retain(|root| used_roots.contains(root)); file_systems_by_skill_path.retain(|path, _| retained_skill_paths.contains(path)); + outcome.skill_roots = skill_roots; + outcome.skill_root_by_path = Arc::new(skill_root_by_path); outcome.file_systems_by_skill_path = SkillFileSystemsByPath::new(file_systems_by_skill_path); fn scope_rank(scope: SkillScope) -> u8 { diff --git a/codex-rs/core-skills/src/model.rs b/codex-rs/core-skills/src/model.rs index eb9a6f132f..0a72c24fe8 100644 --- a/codex-rs/core-skills/src/model.rs +++ b/codex-rs/core-skills/src/model.rs @@ -89,6 +89,8 @@ pub struct SkillLoadOutcome { pub skills: Vec, pub errors: Vec, pub disabled_paths: HashSet, + pub(crate) skill_roots: Vec, + pub(crate) skill_root_by_path: Arc>, pub(crate) file_systems_by_skill_path: SkillFileSystemsByPath, pub(crate) implicit_skills_by_scripts_dir: Arc>, pub(crate) implicit_skills_by_doc_path: Arc>, @@ -176,6 +178,19 @@ pub fn filter_skill_load_outcome_for_product( outcome .file_systems_by_skill_path .retain_paths(&retained_paths); + outcome.skill_root_by_path = Arc::new( + outcome + .skill_root_by_path + .iter() + .filter(|(path, _)| retained_paths.contains(*path)) + .map(|(path, root)| (path.clone(), root.clone())) + .collect(), + ); + let retained_roots: HashSet = + outcome.skill_root_by_path.values().cloned().collect(); + outcome + .skill_roots + .retain(|root| retained_roots.contains(root)); outcome.implicit_skills_by_scripts_dir = Arc::new( outcome .implicit_skills_by_scripts_dir diff --git a/codex-rs/core-skills/src/render.rs b/codex-rs/core-skills/src/render.rs index add2fcaf55..002ee1b3a4 100644 --- a/codex-rs/core-skills/src/render.rs +++ b/codex-rs/core-skills/src/render.rs @@ -1,3 +1,9 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::Component; +use std::path::Path; + +use crate::model::SkillLoadOutcome; use crate::model::SkillMetadata; use codex_otel::SessionTelemetry; use codex_otel::THREAD_SKILLS_DESCRIPTION_TRUNCATED_CHARS_METRIC; @@ -5,6 +11,7 @@ use codex_otel::THREAD_SKILLS_ENABLED_TOTAL_METRIC; use codex_otel::THREAD_SKILLS_KEPT_TOTAL_METRIC; use codex_otel::THREAD_SKILLS_TRUNCATED_METRIC; use codex_protocol::protocol::SkillScope; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::approx_token_count; const DEFAULT_SKILL_METADATA_CHAR_BUDGET: usize = 8_000; @@ -14,6 +21,66 @@ const APPROX_BYTES_PER_TOKEN: usize = 4; pub const SKILL_DESCRIPTION_TRUNCATED_WARNING_PREFIX: &str = "Warning: Exceeded skills context budget. Loaded skill descriptions were truncated by an average of"; pub const SKILL_DESCRIPTIONS_REMOVED_WARNING_PREFIX: &str = "Warning: Exceeded skills context budget. All skill descriptions were removed and"; +pub const SKILLS_INTRO_WITH_ABSOLUTE_PATHS: &str = "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill."; +pub const SKILLS_INTRO_WITH_ALIASES: &str = "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and a short path that can be expanded into an absolute path using the skill roots table."; +pub const SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS: &str = r###"- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths. +- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned. +- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback. +- How to use a skill (progressive disclosure): + 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow. + 2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed. + 3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything. + 4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks. + 5) If `assets/` or templates exist, reuse them instead of recreating from scratch. +- Coordination and sequencing: + - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. + - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. +- Context hygiene: + - Keep context small: summarize long sections instead of pasting them; only load extra files when needed. + - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked. + - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. +- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###; +pub const SKILLS_HOW_TO_USE_WITH_ALIASES: &str = r###"- Discovery: The list above is the skills available in this session (name + description + short path). Skill bodies live on disk at the listed paths after expanding the matching alias from `### Skill roots`. +- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned. +- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback. +- How to use a skill (progressive disclosure): + 1) After deciding to use a skill, expand the listed short `path` with the matching alias from `### Skill roots`, then open its `SKILL.md`. Read only enough to follow the workflow. + 2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the directory containing that expanded `SKILL.md` first, and only consider other paths if needed. + 3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything. + 4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks. + 5) If `assets/` or templates exist, reuse them instead of recreating from scratch. +- Coordination and sequencing: + - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. + - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. +- Context hygiene: + - Keep context small: summarize long sections instead of pasting them; only load extra files when needed. + - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked. + - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. +- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###; + +pub fn render_available_skills_body(skill_root_lines: &[String], skill_lines: &[String]) -> String { + let mut lines: Vec = Vec::new(); + lines.push("## Skills".to_string()); + if skill_root_lines.is_empty() { + lines.push(SKILLS_INTRO_WITH_ABSOLUTE_PATHS.to_string()); + } else { + lines.push(SKILLS_INTRO_WITH_ALIASES.to_string()); + lines.push("### Skill roots".to_string()); + lines.extend(skill_root_lines.iter().cloned()); + } + lines.push("### Available skills".to_string()); + lines.extend(skill_lines.iter().cloned()); + + lines.push("### How to use skills".to_string()); + let how_to_use = if skill_root_lines.is_empty() { + SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS + } else { + SKILLS_HOW_TO_USE_WITH_ALIASES + }; + lines.push(how_to_use.to_string()); + + format!("\n{}\n", lines.join("\n")) +} #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SkillMetadataBudget { @@ -66,6 +133,7 @@ pub enum SkillRenderSideEffects<'a> { #[derive(Debug, Clone, PartialEq, Eq)] pub struct AvailableSkills { + pub skill_root_lines: Vec, pub skill_lines: Vec, pub report: SkillRenderReport, pub warning_message: Option, @@ -89,10 +157,11 @@ pub fn default_skill_metadata_budget(context_window: Option) -> SkillMetada } pub fn build_available_skills( - skills: &[SkillMetadata], + outcome: &SkillLoadOutcome, budget: SkillMetadataBudget, side_effects: SkillRenderSideEffects<'_>, ) -> Option { + let skills = outcome.allowed_skills_for_implicit_invocation(); if skills.is_empty() { record_skill_render_side_effects( side_effects, @@ -104,7 +173,42 @@ pub fn build_available_skills( return None; } - let (skill_lines, report) = render_skill_lines(skills, budget); + let absolute_lines = ordered_absolute_skill_lines(&skills); + let absolute = build_available_skills_from_lines( + absolute_lines, + skills.len(), + budget, + SkillPathAliases::default(), + )?; + + let selected = + if absolute.report.omitted_count == 0 && absolute.report.truncated_description_chars == 0 { + absolute + } else if let Some(aliased) = build_aliased_available_skills(outcome, &skills, budget) { + if aliased_render_is_better(&aliased, &absolute, budget) { + aliased + } else { + absolute + } + } else { + absolute + }; + + record_available_skills_side_effects(&selected, budget, side_effects); + Some(selected) +} + +fn build_available_skills_from_lines( + skill_lines: Vec>, + total_count: usize, + budget: SkillMetadataBudget, + path_aliases: SkillPathAliases, +) -> Option { + if total_count == 0 { + return None; + } + + let (skill_lines, report) = render_skill_lines_from_lines(skill_lines, total_count, budget); let warning_message = if report.omitted_count > 0 { let skill_word = if report.omitted_count == 1 { "skill" @@ -134,29 +238,39 @@ pub fn build_available_skills( } else { None }; - record_skill_render_side_effects( - side_effects, - report.total_count, - report.included_count, - report.omitted_count, - report.truncated_description_chars, - ); - if report.omitted_count > 0 || report.truncated_description_chars > 0 { - tracing::info!( - budget_limit = budget.limit(), - total_skills = report.total_count, - included_skills = report.included_count, - omitted_skills = report.omitted_count, - truncated_description_chars_per_skill = report.average_truncated_description_chars(), - truncated_skill_descriptions = report.truncated_description_count, - "truncated skill metadata to fit skills context budget" - ); - } - Some(AvailableSkills { + let available = AvailableSkills { + skill_root_lines: path_aliases.skill_root_lines, skill_lines, report, warning_message, - }) + }; + Some(available) +} + +fn record_available_skills_side_effects( + available: &AvailableSkills, + budget: SkillMetadataBudget, + side_effects: SkillRenderSideEffects<'_>, +) { + record_skill_render_side_effects( + side_effects, + available.report.total_count, + available.report.included_count, + available.report.omitted_count, + available.report.truncated_description_chars, + ); + if available.report.omitted_count > 0 || available.report.truncated_description_chars > 0 { + tracing::info!( + budget_limit = budget.limit(), + total_skills = available.report.total_count, + included_skills = available.report.included_count, + omitted_skills = available.report.omitted_count, + truncated_description_chars_per_skill = + available.report.average_truncated_description_chars(), + truncated_skill_descriptions = available.report.truncated_description_count, + "truncated skill metadata to fit skills context budget" + ); + } } fn budget_warning_prefix(budget: SkillMetadataBudget, prefix: &str) -> String { @@ -204,16 +318,11 @@ fn record_skill_render_side_effects( } } -fn render_skill_lines( - skills: &[SkillMetadata], +fn render_skill_lines_from_lines( + skill_lines: Vec>, + total_count: usize, budget: SkillMetadataBudget, ) -> (Vec, SkillRenderReport) { - let ordered_skills = ordered_skills_for_budget(skills); - let skill_lines = ordered_skills - .into_iter() - .map(SkillLine::new) - .collect::>(); - let full_cost = skill_lines.iter().fold(0usize, |used, line| { used.saturating_add(line.full_cost(budget)) }); @@ -226,7 +335,7 @@ fn render_skill_lines( return ( included, skill_render_report( - /*total_count*/ skills.len(), + total_count, /*included_count*/ skill_lines.len(), /*omitted_count*/ 0, /*truncated_description_chars*/ 0, @@ -254,7 +363,7 @@ fn render_skill_lines( return ( included, skill_render_report( - /*total_count*/ skills.len(), + total_count, /*included_count*/ skill_lines.len(), /*omitted_count*/ 0, truncated_description_chars, @@ -263,7 +372,7 @@ fn render_skill_lines( ); } - render_minimum_skill_lines_until_budget(budget, skill_lines, skills.len()) + render_minimum_skill_lines_until_budget(budget, skill_lines, total_count) } fn render_minimum_skill_lines_until_budget( @@ -366,10 +475,17 @@ fn sum_description_truncation(rendered: &[RenderedSkillLine]) -> (usize, usize) impl<'a> SkillLine<'a> { fn new(skill: &'a SkillMetadata) -> Self { + Self::with_path( + skill, + skill.path_to_skills_md.to_string_lossy().replace('\\', "/"), + ) + } + + fn with_path(skill: &'a SkillMetadata, path: String) -> Self { Self { name: skill.name.as_str(), description: skill.description.as_str(), - path: skill.path_to_skills_md.to_string_lossy().replace('\\', "/"), + path, } } @@ -455,6 +571,12 @@ fn line_cost(budget: SkillMetadataBudget, line: &str) -> usize { budget.cost(&format!("{line}\n")) } +fn lines_cost(budget: SkillMetadataBudget, lines: &[String]) -> usize { + lines.iter().fold(0usize, |used, line| { + used.saturating_add(line_cost(budget, line)) + }) +} + fn render_lines_with_description_budget( budget: SkillMetadataBudget, skill_lines: &[SkillLine<'_>], @@ -510,6 +632,253 @@ fn render_lines_with_description_budget( .collect() } +fn build_aliased_available_skills( + outcome: &SkillLoadOutcome, + skills: &[SkillMetadata], + budget: SkillMetadataBudget, +) -> Option { + let plan = build_alias_plan(outcome, skills, budget)?; + if plan.table_cost >= budget.limit() { + return None; + } + + let adjusted_limit = budget.limit().saturating_sub(plan.table_cost); + let adjusted_budget = match budget { + SkillMetadataBudget::Tokens(_) => SkillMetadataBudget::Tokens(adjusted_limit), + SkillMetadataBudget::Characters(_) => SkillMetadataBudget::Characters(adjusted_limit), + }; + let ordered_skills = ordered_skills_for_budget(skills); + let skill_lines = ordered_skills + .into_iter() + .map(|skill| SkillLine::with_path(skill, render_skill_path_with_aliases(skill, &plan))) + .collect::>(); + build_available_skills_from_lines(skill_lines, skills.len(), adjusted_budget, plan.aliases) +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct SkillPathAliases { + skill_root_lines: Vec, +} + +struct AliasPlan { + aliases: SkillPathAliases, + root_aliases: HashMap, + alias_root_by_path: HashMap, + table_cost: usize, +} + +fn build_alias_plan( + outcome: &SkillLoadOutcome, + skills: &[SkillMetadata], + budget: SkillMetadataBudget, +) -> Option { + let skill_paths = skills + .iter() + .map(|skill| skill.path_to_skills_md.clone()) + .collect::>(); + let skill_root_by_path = outcome + .skill_root_by_path + .iter() + .filter(|(path, _)| skill_paths.contains(*path)) + .map(|(path, root)| (path.clone(), root.clone())) + .collect::>(); + let used_roots = outcome + .skill_roots + .iter() + .filter(|root| { + skill_root_by_path + .values() + .any(|skill_root| skill_root == *root) + }) + .cloned() + .collect::>(); + if used_roots.is_empty() { + return None; + } + + let plugin_version_skill_counts = + plugin_version_skill_counts_for_skill_roots(skill_root_by_path.values()); + let alias_root_by_skill_root = used_roots + .iter() + .map(|root| { + ( + root.clone(), + alias_root_for_skill_root(root, &plugin_version_skill_counts), + ) + }) + .collect::>(); + let alias_roots = ordered_alias_roots(&used_roots, &alias_root_by_skill_root)?; + let root_aliases = alias_roots + .iter() + .enumerate() + .map(|(index, alias_root)| (alias_root.clone(), format!("r{index}"))) + .collect::>(); + let alias_root_by_path = skill_root_by_path + .iter() + .filter_map(|(path, skill_root)| { + alias_root_by_skill_root + .get(skill_root) + .map(|alias_root| (path.clone(), alias_root.clone())) + }) + .collect::>(); + let skill_root_lines = build_skill_root_lines(&alias_roots); + let table_cost = aliased_metadata_overhead_cost(budget, &skill_root_lines); + + Some(AliasPlan { + aliases: SkillPathAliases { skill_root_lines }, + root_aliases, + alias_root_by_path, + table_cost, + }) +} + +fn ordered_alias_roots( + used_roots: &[AbsolutePathBuf], + alias_root_by_skill_root: &HashMap, +) -> Option> { + let mut seen = HashSet::new(); + let mut alias_roots = Vec::new(); + for root in used_roots { + let alias_root = alias_root_by_skill_root.get(root)?.clone(); + if seen.insert(alias_root.clone()) { + alias_roots.push(alias_root); + } + } + Some(alias_roots) +} + +fn alias_root_for_skill_root( + root: &AbsolutePathBuf, + plugin_version_skill_counts: &HashMap, +) -> AbsolutePathBuf { + let Some(plugin_version_base) = plugin_version_base(root.as_path()) else { + return root.clone(); + }; + let skill_count = plugin_version_skill_counts + .get(&plugin_version_base) + .copied() + .unwrap_or_default(); + if skill_count > 1 { + root.clone() + } else { + plugin_marketplace_base(root.as_path()).unwrap_or_else(|| root.clone()) + } +} + +fn plugin_version_skill_counts_for_skill_roots<'a>( + skill_roots: impl Iterator, +) -> HashMap { + let mut counts = HashMap::new(); + for root in skill_roots { + if let Some(plugin_version_base) = plugin_version_base(root.as_path()) { + let count = counts.entry(plugin_version_base).or_insert(0usize); + *count = count.saturating_add(1); + } + } + counts +} + +fn aliased_metadata_overhead_cost( + budget: SkillMetadataBudget, + skill_root_lines: &[String], +) -> usize { + let empty_skill_lines: &[String] = &[]; + let absolute_body = render_available_skills_body(&[], empty_skill_lines); + let aliased_body = render_available_skills_body(skill_root_lines, empty_skill_lines); + budget + .cost(&aliased_body) + .saturating_sub(budget.cost(&absolute_body)) +} + +fn build_skill_root_lines(roots: &[AbsolutePathBuf]) -> Vec { + roots + .iter() + .enumerate() + .map(|(index, root)| { + let root_str = root.to_string_lossy().replace('\\', "/"); + format!("- `r{index}` = `{root_str}`") + }) + .collect() +} + +fn plugin_marketplace_base(path: &Path) -> Option { + let mut candidate = path; + while let Some(parent) = candidate.parent() { + if parent.file_name()?.to_str()? == "cache" + && parent.parent()?.file_name()?.to_str()? == "plugins" + { + return AbsolutePathBuf::from_absolute_path(candidate).ok(); + } + candidate = parent; + } + None +} + +fn plugin_version_base(path: &Path) -> Option { + let marketplace_base = plugin_marketplace_base(path)?; + let mut relative_components = path + .strip_prefix(marketplace_base.as_path()) + .ok()? + .components(); + let plugin = match relative_components.next()? { + Component::Normal(plugin) => plugin, + _ => return None, + }; + let version = match relative_components.next()? { + Component::Normal(version) => version, + _ => return None, + }; + AbsolutePathBuf::from_absolute_path(marketplace_base.join(plugin).join(version)).ok() +} + +fn render_skill_path_with_aliases(skill: &SkillMetadata, plan: &AliasPlan) -> String { + outcome_relative_skill_path(skill, plan) + .unwrap_or_else(|| skill.path_to_skills_md.to_string_lossy().replace('\\', "/")) +} + +fn outcome_relative_skill_path(skill: &SkillMetadata, plan: &AliasPlan) -> Option { + let alias_root = plan.alias_root_by_path.get(&skill.path_to_skills_md)?; + let alias = plan.root_aliases.get(alias_root)?; + let relative_path = skill + .path_to_skills_md + .as_path() + .strip_prefix(alias_root.as_path()) + .ok()?; + let relative_path = relative_path.to_string_lossy().replace('\\', "/"); + Some(format!("{alias}/{relative_path}")) +} + +fn aliased_render_is_better( + aliased: &AvailableSkills, + absolute: &AvailableSkills, + budget: SkillMetadataBudget, +) -> bool { + if aliased.report.included_count != absolute.report.included_count { + return aliased.report.included_count > absolute.report.included_count; + } + if aliased.report.truncated_description_chars != absolute.report.truncated_description_chars { + return aliased.report.truncated_description_chars + < absolute.report.truncated_description_chars; + } + available_skills_cost(budget, aliased) < available_skills_cost(budget, absolute) +} + +fn available_skills_cost(budget: SkillMetadataBudget, available: &AvailableSkills) -> usize { + let metadata_cost = if available.skill_root_lines.is_empty() { + 0 + } else { + aliased_metadata_overhead_cost(budget, &available.skill_root_lines) + }; + metadata_cost.saturating_add(lines_cost(budget, &available.skill_lines)) +} + +fn ordered_absolute_skill_lines(skills: &[SkillMetadata]) -> Vec> { + ordered_skills_for_budget(skills) + .into_iter() + .map(SkillLine::new) + .collect() +} + fn ordered_skills_for_budget(skills: &[SkillMetadata]) -> Vec<&SkillMetadata> { let mut ordered = skills.iter().collect::>(); ordered.sort_by(|a, b| { @@ -533,6 +902,9 @@ fn prompt_scope_rank(scope: SkillScope) -> u8 { #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; + use std::sync::Arc; + use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use pretty_assertions::assert_eq; @@ -564,6 +936,48 @@ mod tests { SkillLine::new(skill).render_with_description(description) } + fn normalized_path(path: &AbsolutePathBuf) -> String { + path.to_string_lossy().replace('\\', "/") + } + + fn outcome_with_roots( + skills: Vec, + roots: Vec, + ) -> SkillLoadOutcome { + let skill_root_by_path = skills + .iter() + .filter_map(|skill| { + roots + .iter() + .find(|root| { + skill + .path_to_skills_md + .as_path() + .starts_with(root.as_path()) + }) + .map(|root| (skill.path_to_skills_md.clone(), root.clone())) + }) + .collect::>(); + SkillLoadOutcome { + skills, + skill_roots: roots, + skill_root_by_path: Arc::new(skill_root_by_path), + ..Default::default() + } + } + + fn build_available_skills_from_metadata( + skills: &[SkillMetadata], + budget: SkillMetadataBudget, + ) -> Option { + build_available_skills_from_lines( + ordered_absolute_skill_lines(skills), + skills.len(), + budget, + SkillPathAliases::default(), + ) + } + #[test] fn default_budget_uses_two_percent_of_full_context_window() { assert_eq!( @@ -597,12 +1011,8 @@ mod tests { + SkillLine::new(&beta).minimum_cost(SkillMetadataBudget::Characters(usize::MAX)); let budget = SkillMetadataBudget::Characters(minimum_cost + 6); - let rendered = build_available_skills( - &[beta.clone(), alpha.clone()], - budget, - SkillRenderSideEffects::None, - ) - .expect("skills should render"); + let rendered = build_available_skills_from_metadata(&[beta.clone(), alpha.clone()], budget) + .expect("skills should render"); assert_eq!(rendered.report.included_count, 2); assert_eq!(rendered.report.omitted_count, 0); @@ -626,7 +1036,7 @@ mod tests { + SkillLine::new(&beta).minimum_cost(SkillMetadataBudget::Characters(usize::MAX)); let budget = SkillMetadataBudget::Characters(minimum_cost + 6); - let rendered = build_available_skills(&[alpha, beta], budget, SkillRenderSideEffects::None) + let rendered = build_available_skills_from_metadata(&[alpha, beta], budget) .expect("skills should render"); assert_eq!(rendered.report.included_count, 2); @@ -646,7 +1056,7 @@ mod tests { + SkillLine::new(&beta).minimum_cost(SkillMetadataBudget::Characters(usize::MAX)); let budget = SkillMetadataBudget::Characters(minimum_cost + 6); - let rendered = build_available_skills(&[alpha, beta], budget, SkillRenderSideEffects::None) + let rendered = build_available_skills_from_metadata(&[alpha, beta], budget) .expect("skills should render"); assert_eq!(rendered.report.included_count, 2); @@ -671,12 +1081,8 @@ mod tests { + SkillLine::new(&long).minimum_cost(SkillMetadataBudget::Characters(usize::MAX)); let budget = SkillMetadataBudget::Characters(minimum_cost + 11); - let rendered = build_available_skills( - &[short.clone(), long.clone()], - budget, - SkillRenderSideEffects::None, - ) - .expect("skills should render"); + let rendered = build_available_skills_from_metadata(&[short.clone(), long.clone()], budget) + .expect("skills should render"); assert_eq!(rendered.report.included_count, 2); assert_eq!(rendered.report.omitted_count, 0); @@ -702,12 +1108,8 @@ mod tests { .cost(&format!("{}\n", SkillLine::new(&admin).render_minimum())); let budget = SkillMetadataBudget::Characters(system_cost + admin_cost); - let rendered = build_available_skills( - &[system, user, repo, admin], - budget, - SkillRenderSideEffects::None, - ) - .expect("skills should render"); + let rendered = build_available_skills_from_metadata(&[system, user, repo, admin], budget) + .expect("skills should render"); assert_eq!(rendered.report.included_count, 2); assert_eq!(rendered.report.omitted_count, 2); @@ -735,9 +1137,8 @@ mod tests { .cost(&format!("{}\n", SkillLine::new(&repo).render_full())); let budget = SkillMetadataBudget::Characters(repo_cost); - let rendered = - build_available_skills(&[oversized, repo], budget, SkillRenderSideEffects::None) - .expect("skills render"); + let rendered = build_available_skills_from_metadata(&[oversized, repo], budget) + .expect("skills render"); assert_eq!(rendered.report.included_count, 1); assert_eq!(rendered.report.omitted_count, 1); @@ -752,4 +1153,335 @@ mod tests { assert!(!rendered_text.contains("- oversized-system-skill:")); assert!(rendered_text.contains("- repo-skill:")); } + + #[test] + fn outcome_rendering_omits_aliases_when_absolute_plan_has_no_budget_pressure() { + let root = test_path_buf("/tmp/skills").abs(); + let alpha_path = root.join("alpha/SKILL.md"); + let beta_path = root.join("beta/SKILL.md"); + let outcome = outcome_with_roots( + vec![ + skill_with_path("alpha-skill", &alpha_path), + skill_with_path("beta-skill", &beta_path), + ], + vec![root], + ); + + let rendered = build_available_skills( + &outcome, + SkillMetadataBudget::Characters(usize::MAX), + SkillRenderSideEffects::None, + ) + .expect("skills should render"); + + assert!(rendered.skill_root_lines.is_empty()); + assert_eq!(rendered.report.included_count, 2); + } + + #[test] + fn outcome_rendering_uses_aliases_when_they_allow_more_skills_to_fit() { + let root = test_path_buf( + "/Users/xl/.codex/plugins/cache/openai-curated/example/hash1234567890/skills-with-a-very-long-shared-prefix", + ) + .abs(); + let skills = (0..12) + .map(|index| { + let name = format!("shared-root-skill-{index}"); + skill_with_path(&name, &root.join(format!("skill-{index}/SKILL.md"))) + }) + .collect::>(); + let outcome = outcome_with_roots(skills.clone(), vec![root]); + let absolute_minimum = skills.iter().fold(0usize, |cost, skill| { + cost.saturating_add( + SkillLine::new(skill).minimum_cost(SkillMetadataBudget::Characters(usize::MAX)), + ) + }); + let plan = build_alias_plan( + &outcome, + &skills, + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("alias plan should build"); + let alias_minimum = skills.iter().fold(plan.table_cost, |cost, skill| { + cost.saturating_add( + SkillLine::with_path(skill, render_skill_path_with_aliases(skill, &plan)) + .minimum_cost(SkillMetadataBudget::Characters(usize::MAX)), + ) + }); + assert!( + alias_minimum < absolute_minimum, + "test fixture should make aliases cheaper" + ); + + let rendered = build_available_skills( + &outcome, + SkillMetadataBudget::Characters(alias_minimum), + SkillRenderSideEffects::None, + ) + .expect("skills should render"); + + assert_eq!(rendered.report.included_count, skills.len()); + assert_eq!(rendered.report.omitted_count, 0); + assert_eq!( + rendered.skill_root_lines, + vec![format!( + "- `r0` = `{}`", + normalized_path( + &test_path_buf( + "/Users/xl/.codex/plugins/cache/openai-curated/example/hash1234567890/skills-with-a-very-long-shared-prefix" + ) + .abs() + ) + )] + ); + let rendered_text = rendered.skill_lines.join("\n"); + assert!(rendered_text.contains("r0/skill-0/SKILL.md")); + assert!(rendered_text.contains("r0/skill-11/SKILL.md")); + } + + #[test] + fn outcome_rendering_uses_marketplace_root_for_single_skill_plugin_versions() { + let github_root = + test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/skills") + .abs(); + let marketplace_root = test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated").abs(); + let github = skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md")); + let outcome = outcome_with_roots(vec![github.clone()], vec![github_root.clone()]); + let plan = build_alias_plan( + &outcome, + &[github], + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("alias plan should build"); + + assert_eq!( + plan.aliases.skill_root_lines, + vec![format!("- `r0` = `{}`", normalized_path(&marketplace_root))] + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md")), + &plan + ), + "r0/github/hash123/skills/gh-fix-ci/SKILL.md" + ); + } + + #[test] + fn outcome_rendering_uses_skill_root_for_multiple_skills_in_one_plugin_version() { + let github_root = + test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/skills") + .abs(); + let fix_ci = skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md")); + let yeet = skill_with_path("github:yeet", &github_root.join("yeet/SKILL.md")); + let outcome = outcome_with_roots( + vec![fix_ci.clone(), yeet.clone()], + vec![github_root.clone()], + ); + let plan = build_alias_plan( + &outcome, + &[fix_ci, yeet], + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("alias plan should build"); + + assert_eq!( + plan.aliases.skill_root_lines, + vec![format!("- `r0` = `{}`", normalized_path(&github_root))] + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md")), + &plan + ), + "r0/gh-fix-ci/SKILL.md" + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:yeet", &github_root.join("yeet/SKILL.md")), + &plan + ), + "r0/yeet/SKILL.md" + ); + } + + #[test] + fn outcome_rendering_counts_plugin_version_skills_before_budget_omission() { + let root = test_path_buf( + "/Users/xl/.codex/plugins/cache/openai-curated/example/hash1234567890/skills-with-a-very-long-shared-prefix", + ) + .abs(); + let alpha = skill_with_path("alpha-skill", &root.join("alpha/SKILL.md")); + let beta = skill_with_path("beta-skill", &root.join("beta/SKILL.md")); + let outcome = outcome_with_roots(vec![alpha.clone(), beta.clone()], vec![root.clone()]); + let plan = build_alias_plan( + &outcome, + &[alpha.clone(), beta.clone()], + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("alias plan should build"); + let alpha_cost = SkillMetadataBudget::Characters(usize::MAX).cost(&format!( + "{}\n", + SkillLine::with_path(&alpha, render_skill_path_with_aliases(&alpha, &plan)) + .render_minimum() + )); + let rendered = build_aliased_available_skills( + &outcome, + &[alpha, beta], + SkillMetadataBudget::Characters(plan.table_cost + alpha_cost), + ) + .expect("skills should render"); + + assert_eq!(rendered.report.included_count, 1); + assert_eq!( + rendered.skill_root_lines, + vec![format!("- `r0` = `{}`", normalized_path(&root))] + ); + assert_eq!( + rendered.skill_lines, + vec!["- alpha-skill: (file: r0/alpha/SKILL.md)"] + ); + } + + #[test] + fn outcome_rendering_uses_each_skill_root_for_multiple_roots_in_one_plugin_version() { + let skills_root = + test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/skills") + .abs(); + let extra_root = test_path_buf( + "/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/extra-skills", + ) + .abs(); + let fix_ci = skill_with_path("github:gh-fix-ci", &skills_root.join("gh-fix-ci/SKILL.md")); + let yeet = skill_with_path("github:yeet", &extra_root.join("yeet/SKILL.md")); + let outcome = outcome_with_roots( + vec![fix_ci.clone(), yeet.clone()], + vec![skills_root.clone(), extra_root.clone()], + ); + let plan = build_alias_plan( + &outcome, + &[fix_ci, yeet], + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("alias plan should build"); + + assert_eq!( + plan.aliases.skill_root_lines, + vec![ + format!("- `r0` = `{}`", normalized_path(&skills_root)), + format!("- `r1` = `{}`", normalized_path(&extra_root)), + ] + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:gh-fix-ci", &skills_root.join("gh-fix-ci/SKILL.md")), + &plan + ), + "r0/gh-fix-ci/SKILL.md" + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:yeet", &extra_root.join("yeet/SKILL.md")), + &plan + ), + "r1/yeet/SKILL.md" + ); + } + + #[test] + fn outcome_rendering_extracts_plugin_marketplace_root_for_multiple_plugins() { + let github_root = + test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/skills") + .abs(); + let slack_root = + test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/slack/hash456/skills") + .abs(); + let marketplace_root = test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated").abs(); + let github = skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md")); + let slack = skill_with_path( + "slack:daily-digest", + &slack_root.join("daily-digest/SKILL.md"), + ); + let outcome = outcome_with_roots( + vec![github.clone(), slack.clone()], + vec![github_root.clone(), slack_root.clone()], + ); + let plan = build_alias_plan( + &outcome, + &[github, slack], + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("alias plan should build"); + + assert_eq!( + plan.aliases.skill_root_lines, + vec![format!("- `r0` = `{}`", normalized_path(&marketplace_root))] + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:gh-fix-ci", &github_root.join("gh-fix-ci/SKILL.md")), + &plan + ), + "r0/github/hash123/skills/gh-fix-ci/SKILL.md" + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path( + "slack:daily-digest", + &slack_root.join("daily-digest/SKILL.md") + ), + &plan + ), + "r0/slack/hash456/skills/daily-digest/SKILL.md" + ); + } + + #[test] + fn outcome_rendering_uses_one_marketplace_root_for_multiple_plugin_versions() { + let skills_root = + test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated/github/hash123/skills") + .abs(); + let extra_root = test_path_buf( + "/Users/xl/.codex/plugins/cache/openai-curated/github/hash456/extra-skills", + ) + .abs(); + let marketplace_root = test_path_buf("/Users/xl/.codex/plugins/cache/openai-curated").abs(); + let fix_ci = skill_with_path("github:gh-fix-ci", &skills_root.join("gh-fix-ci/SKILL.md")); + let yeet = skill_with_path("github:yeet", &extra_root.join("yeet/SKILL.md")); + let outcome = outcome_with_roots( + vec![fix_ci.clone(), yeet.clone()], + vec![skills_root.clone(), extra_root.clone()], + ); + let plan = build_alias_plan( + &outcome, + &[fix_ci, yeet], + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("alias plan should build"); + + assert_eq!( + plan.aliases.skill_root_lines, + vec![format!("- `r0` = `{}`", normalized_path(&marketplace_root))] + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:gh-fix-ci", &skills_root.join("gh-fix-ci/SKILL.md")), + &plan + ), + "r0/github/hash123/skills/gh-fix-ci/SKILL.md" + ); + assert_eq!( + render_skill_path_with_aliases( + &skill_with_path("github:yeet", &extra_root.join("yeet/SKILL.md")), + &plan + ), + "r0/github/hash456/extra-skills/yeet/SKILL.md" + ); + } + + fn skill_with_path(name: &str, path: &AbsolutePathBuf) -> SkillMetadata { + let mut skill = make_skill(name, SkillScope::User); + skill.path_to_skills_md = path.clone(); + skill + } } diff --git a/codex-rs/core/src/context/available_skills_instructions.rs b/codex-rs/core/src/context/available_skills_instructions.rs index aba4b20135..0a99bf62e6 100644 --- a/codex-rs/core/src/context/available_skills_instructions.rs +++ b/codex-rs/core/src/context/available_skills_instructions.rs @@ -1,4 +1,5 @@ use codex_core_skills::AvailableSkills; +use codex_core_skills::render_available_skills_body; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; @@ -6,12 +7,14 @@ use super::ContextualUserFragment; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct AvailableSkillsInstructions { + skill_root_lines: Vec, skill_lines: Vec, } impl From for AvailableSkillsInstructions { fn from(available_skills: AvailableSkills) -> Self { Self { + skill_root_lines: available_skills.skill_root_lines, skill_lines: available_skills.skill_lines, } } @@ -23,34 +26,6 @@ impl ContextualUserFragment for AvailableSkillsInstructions { const END_MARKER: &'static str = SKILLS_INSTRUCTIONS_CLOSE_TAG; fn body(&self) -> String { - let mut lines: Vec = Vec::new(); - lines.push("## Skills".to_string()); - lines.push("A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.".to_string()); - lines.push("### Available skills".to_string()); - lines.extend(self.skill_lines.iter().cloned()); - - lines.push("### How to use skills".to_string()); - lines.push( - r###"- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths. -- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned. -- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback. -- How to use a skill (progressive disclosure): - 1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow. - 2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed. - 3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything. - 4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks. - 5) If `assets/` or templates exist, reuse them instead of recreating from scratch. -- Coordination and sequencing: - - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. - - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. -- Context hygiene: - - Keep context small: summarize long sections instead of pasting them; only load extra files when needed. - - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked. - - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. -- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."### - .to_string(), - ); - - format!("\n{}\n", lines.join("\n")) + render_available_skills_body(&self.skill_root_lines, &self.skill_lines) } } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index ca865300a3..b643be065f 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2627,12 +2627,8 @@ impl Session { } } if turn_context.config.include_skill_instructions { - let implicit_skills = turn_context - .turn_skills - .outcome - .allowed_skills_for_implicit_invocation(); let available_skills = build_available_skills( - &implicit_skills, + &turn_context.turn_skills.outcome, default_skill_metadata_budget(turn_context.model_info.context_window), SkillRenderSideEffects::ThreadStart { session_telemetry: &self.services.session_telemetry, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 227f586a01..3208f97dcb 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5185,17 +5185,19 @@ async fn build_initial_context_trims_skill_metadata_from_context_window_budget() #[test] fn emit_thread_start_skill_metrics_records_enabled_kept_and_truncated_values() { let session_telemetry = test_session_telemetry_without_metadata(); + let mut outcome = SkillLoadOutcome::default(); + outcome.skills = vec![SkillMetadata { + name: "repo-skill".to_string(), + description: "desc".to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: test_path_buf("/tmp/repo-skill/SKILL.md").abs(), + scope: SkillScope::Repo, + }]; let rendered = build_available_skills( - &[SkillMetadata { - name: "repo-skill".to_string(), - description: "desc".to_string(), - short_description: None, - interface: None, - dependencies: None, - policy: None, - path_to_skills_md: test_path_buf("/tmp/repo-skill/SKILL.md").abs(), - scope: SkillScope::Repo, - }], + &outcome, SkillMetadataBudget::Characters(1), SkillRenderSideEffects::ThreadStart { session_telemetry: &session_telemetry, @@ -5255,9 +5257,11 @@ fn emit_thread_start_skill_metrics_records_description_truncated_chars_without_o .count() }; let minimum_budget = minimum_skill_line_cost(&alpha) + minimum_skill_line_cost(&beta); + let mut outcome = SkillLoadOutcome::default(); + outcome.skills = vec![alpha, beta]; let rendered = build_available_skills( - &[alpha, beta], + &outcome, SkillMetadataBudget::Characters(minimum_budget + 6), SkillRenderSideEffects::ThreadStart { session_telemetry: &session_telemetry, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 48b79b5b66..67751161d3 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1,3 +1,4 @@ +use codex_config::ConfigLayerStack; use codex_config::types::AuthCredentialsStoreMode; use codex_core::ModelClient; use codex_core::NewThread; @@ -71,6 +72,7 @@ use std::io::Write; use std::num::NonZeroU64; use std::sync::Arc; use tempfile::TempDir; +use toml::toml; use uuid::Uuid; use wiremock::Mock; use wiremock::MockServer; @@ -1493,6 +1495,95 @@ async fn skills_append_to_developer_message() { let _codex_home_guard = codex_home; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn skills_use_aliases_in_developer_message_under_budget_pressure() { + skip_if_no_network!(); + let server = MockServer::start().await; + + let resp_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + + let codex_home_parent = TempDir::new().unwrap(); + let long_home_parent = codex_home_parent + .path() + .join("codex-home-with-long-shared-prefix-for-skill-alias-budget-test"); + std::fs::create_dir_all(&long_home_parent).expect("create long home parent"); + let codex_home = Arc::new(TempDir::new_in(long_home_parent).unwrap()); + let skill_root = codex_home.path().join("skills"); + for index in 0..12 { + let skill_dir = skill_root.join(format!("s{index:02}")); + std::fs::create_dir_all(&skill_dir).expect("create skill dir"); + std::fs::write( + skill_dir.join("SKILL.md"), + format!("---\nname: s{index:02}\ndescription: d\n---\n\n# body\n"), + ) + .expect("write skill"); + } + + let codex_home_path = codex_home.path().to_path_buf(); + let mut builder = test_codex() + .with_home(codex_home.clone()) + .with_auth(CodexAuth::from_api_key("Test API Key")) + .with_config(move |config| { + config.cwd = codex_home_path.abs(); + let user_config_path = codex_home_path.join("config.toml").abs(); + config.config_layer_stack = ConfigLayerStack::default().with_user_config( + &user_config_path, + toml! { skills = { bundled = { enabled = false } } }.into(), + ); + config.model_context_window = Some(12_000); + }); + let codex = builder + .build(&server) + .await + .expect("create new conversation") + .codex; + + codex + .submit(Op::UserInput { + environments: None, + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request = resp_mock.single_request(); + let developer_messages = request.message_input_texts("developer"); + let developer_text = developer_messages.join("\n\n"); + let expected_root = normalize_path(skill_root).unwrap(); + let expected_root_str = expected_root.to_string_lossy().replace('\\', "/"); + assert!( + developer_text.contains("### Skill roots"), + "expected aliased skills root section: {developer_messages:?}" + ); + assert!( + developer_text.contains(&format!("- `r0` = `{expected_root_str}`")), + "expected root alias for {expected_root_str}: {developer_messages:?}" + ); + assert!( + developer_text.contains("- s00: d (file: r0/s00/SKILL.md)"), + "expected skill path to use root alias: {developer_messages:?}" + ); + assert!( + developer_text.contains( + "expand the listed short `path` with the matching alias from `### Skill roots`" + ), + "expected alias-specific skill instructions: {developer_messages:?}" + ); + let _codex_home_guard = codex_home; + let _codex_home_parent_guard = codex_home_parent; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn includes_configured_effort_in_request() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); From 219c65dc2f7a2fdb2adef73d572189e80b7470e5 Mon Sep 17 00:00:00 2001 From: rreichel3-oai Date: Fri, 24 Apr 2026 18:49:34 -0400 Subject: [PATCH 05/67] [codex] Forward Codex Apps tool call IDs to backend metadata (#19207) ## Summary - include the outer tool `call_id` in Codex Apps MCP request metadata under `_meta._codex_apps.call_id` - preserve existing Codex Apps metadata like `resource_uri` and `contains_mcp_source` - add request metadata coverage for both the existing-metadata and no-existing-metadata cases ## Why The paired backend change in [openai/openai#850796](https://github.com/openai/openai/pull/850796) updates MCP compliance logging to prefer `_meta._codex_apps.call_id` instead of the JSON-RPC request id. This client change sends that outer tool call id so the backend can record the model/tool call identifier when it is available. This is wire-compatible with older backends because `_meta._codex_apps` is already reserved backend-only metadata. Backends that do not read `call_id` will ignore the extra field. ## Testing - `cargo test -p codex-core request_meta` - `just fmt` - `just fix -p codex-core` --- codex-rs/core/src/mcp_tool_call.rs | 21 ++++++++--- codex-rs/core/src/mcp_tool_call_tests.rs | 39 ++++++++++++++++++-- codex-rs/core/tests/suite/openai_file_mcp.rs | 1 + codex-rs/core/tests/suite/search_tool.rs | 2 + 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index a1cae82c70..7a76db9e4f 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -159,8 +159,12 @@ pub(crate) async fn handle_mcp_tool_call( .unwrap_or_else(|| JsonValue::Object(serde_json::Map::new())), }; } - let request_meta = - build_mcp_tool_call_request_meta(turn_context.as_ref(), &server, metadata.as_ref()); + let request_meta = build_mcp_tool_call_request_meta( + turn_context.as_ref(), + &server, + &call_id, + metadata.as_ref(), + ); let connector_id = metadata .as_ref() .and_then(|metadata| metadata.connector_id.clone()); @@ -694,6 +698,7 @@ fn custom_mcp_tool_approval_mode( fn build_mcp_tool_call_request_meta( turn_context: &TurnContext, server: &str, + call_id: &str, metadata: Option<&McpToolApprovalMetadata>, ) -> Option { let mut request_meta = serde_json::Map::new(); @@ -705,10 +710,14 @@ fn build_mcp_tool_call_request_meta( ); } - if server == CODEX_APPS_MCP_SERVER_NAME - && let Some(codex_apps_meta) = - metadata.and_then(|metadata| metadata.codex_apps_meta.clone()) - { + if server == CODEX_APPS_MCP_SERVER_NAME { + let mut codex_apps_meta = metadata + .and_then(|metadata| metadata.codex_apps_meta.clone()) + .unwrap_or_default(); + codex_apps_meta.insert( + "call_id".to_string(), + serde_json::Value::String(call_id.to_string()), + ); request_meta.insert( MCP_TOOL_CODEX_APPS_META_KEY.to_string(), serde_json::Value::Object(codex_apps_meta), diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 7dcc1eabe6..da0c549009 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -669,9 +669,13 @@ async fn mcp_tool_call_request_meta_includes_turn_metadata_for_custom_server() { ) .expect("turn metadata json"); - let meta = - build_mcp_tool_call_request_meta(&turn_context, "custom_server", /*metadata*/ None) - .expect("custom servers should receive turn metadata"); + let meta = build_mcp_tool_call_request_meta( + &turn_context, + "custom_server", + "call-custom", + /*metadata*/ None, + ) + .expect("custom servers should receive turn metadata"); assert_eq!( meta, @@ -716,11 +720,13 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps build_mcp_tool_call_request_meta( &turn_context, CODEX_APPS_MCP_SERVER_NAME, + "call_abc123xyz789", Some(&metadata), ), Some(serde_json::json!({ crate::X_CODEX_TURN_METADATA_HEADER: expected_turn_metadata, MCP_TOOL_CODEX_APPS_META_KEY: { + "call_id": "call_abc123xyz789", "resource_uri": "connector://calendar/tools/calendar_create_event", "contains_mcp_source": true, "connector_id": "calendar", @@ -729,6 +735,33 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps ); } +#[tokio::test] +async fn codex_apps_tool_call_request_meta_includes_call_id_without_existing_codex_apps_meta() { + let (_, turn_context) = make_session_and_context().await; + let expected_turn_metadata = serde_json::from_str::( + &turn_context + .turn_metadata_state + .current_header_value() + .expect("turn metadata header"), + ) + .expect("turn metadata json"); + + assert_eq!( + build_mcp_tool_call_request_meta( + &turn_context, + CODEX_APPS_MCP_SERVER_NAME, + "call_abc123xyz789", + /*metadata*/ None, + ), + Some(serde_json::json!({ + crate::X_CODEX_TURN_METADATA_HEADER: expected_turn_metadata, + MCP_TOOL_CODEX_APPS_META_KEY: { + "call_id": "call_abc123xyz789", + }, + })) + ); +} + #[test] fn mcp_tool_call_thread_id_meta_is_added_to_request_meta() { assert_eq!( diff --git a/codex-rs/core/tests/suite/openai_file_mcp.rs b/codex-rs/core/tests/suite/openai_file_mcp.rs index 2912b5fb0e..3bfa264d79 100644 --- a/codex-rs/core/tests/suite/openai_file_mcp.rs +++ b/codex-rs/core/tests/suite/openai_file_mcp.rs @@ -222,6 +222,7 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res assert_eq!( apps_tool_call.pointer("/params/_meta/_codex_apps"), Some(&json!({ + "call_id": "extract-call-1", "resource_uri": DOCUMENT_EXTRACT_TEXT_RESOURCE_URI, "contains_mcp_source": true, "connector_id": "calendar", diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 1e37d5ae10..fa2eca9b22 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -555,6 +555,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - .structured_content, Some(json!({ "_codex_apps": { + "call_id": "calendar-call-1", "resource_uri": CALENDAR_CREATE_EVENT_RESOURCE_URI, "contains_mcp_source": true, "connector_id": "calendar", @@ -586,6 +587,7 @@ async fn tool_search_returns_deferred_tools_without_follow_up_tool_injection() - assert_eq!( apps_tool_call.pointer("/params/_meta/_codex_apps"), Some(&json!({ + "call_id": "calendar-call-1", "resource_uri": CALENDAR_CREATE_EVENT_RESOURCE_URI, "contains_mcp_source": true, "connector_id": "calendar", From 32aad7bd13e63baf7af96bf3d0c960a566da993e Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Fri, 24 Apr 2026 16:18:30 -0700 Subject: [PATCH 06/67] Serialize legacy Windows PowerShell sandbox tests (#19453) ## Why Recent `main` CI had repeated Windows timeouts in the legacy sandbox process tests: - `codex-windows-sandbox session::tests::legacy_capture_powershell_emits_output` failed in runs [24909500958](https://github.com/openai/codex/actions/runs/24909500958), [24908076251](https://github.com/openai/codex/actions/runs/24908076251), [24906197645](https://github.com/openai/codex/actions/runs/24906197645), [24905411571](https://github.com/openai/codex/actions/runs/24905411571), [24903336028](https://github.com/openai/codex/actions/runs/24903336028), and [24898949647](https://github.com/openai/codex/actions/runs/24898949647). - `legacy_tty_powershell_emits_output_and_accepts_input` failed in the same set of runs. - `legacy_non_tty_cmd_emits_output` failed in runs [24909500958](https://github.com/openai/codex/actions/runs/24909500958), [24908076251](https://github.com/openai/codex/actions/runs/24908076251), [24906197645](https://github.com/openai/codex/actions/runs/24906197645), and [24903336028](https://github.com/openai/codex/actions/runs/24903336028). - `legacy_non_tty_powershell_emits_output` failed in runs [24908076251](https://github.com/openai/codex/actions/runs/24908076251), [24906197645](https://github.com/openai/codex/actions/runs/24906197645), and [24903336028](https://github.com/openai/codex/actions/runs/24903336028). These failures were 30s timeouts on Windows x64 and/or arm64 rather than assertion failures. ## Root Cause The active legacy Windows sandbox process tests all exercise host-level resources: sandbox setup, ACL/user state, private desktop process launch, stdio capture, and PowerShell/cmd child cleanup. Running several of these tests concurrently can leave them competing for the same Windows sandbox setup path and process/session resources, which makes command startup or output collection hang under CI load. ## What Changed - Added a shared in-process mutex for the active legacy Windows sandbox process tests. - Held that guard across each legacy cmd/PowerShell process test so those host-resource-heavy cases run one at a time. - Kept the skipped legacy cmd TTY tests unchanged. ## Why This Should Be Reliable The tests still use unique homes and run the real legacy sandbox process path, but they no longer overlap the fragile host-level setup and process/session lifecycle. Serializing just this small group removes the concurrency race without reducing the behavioral coverage of each test. ## Verification - `cargo test -p codex-windows-sandbox` - GitHub Windows CI is the primary validation signal for the affected tests; on this PR, Windows clippy, Windows release, and Windows local Bazel passed after the serialization fix. --- .../windows-sandbox-rs/src/unified_exec/tests.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs index a2bc7bebf0..b0530a4fb4 100644 --- a/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/tests.rs @@ -14,6 +14,8 @@ use std::io::Seek; use std::io::SeekFrom; use std::path::Path; use std::path::PathBuf; +use std::sync::Mutex; +use std::sync::MutexGuard; use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; use std::time::Duration; @@ -26,6 +28,13 @@ use tokio::sync::oneshot; use tokio::time::timeout; static TEST_HOME_COUNTER: AtomicU64 = AtomicU64::new(0); +static LEGACY_PROCESS_TEST_LOCK: Mutex<()> = Mutex::new(()); + +fn legacy_process_test_guard() -> MutexGuard<'static, ()> { + LEGACY_PROCESS_TEST_LOCK + .lock() + .expect("legacy Windows sandbox process test lock poisoned") +} fn current_thread_runtime() -> tokio::runtime::Runtime { Builder::new_current_thread() @@ -129,6 +138,7 @@ async fn collect_stdout_and_exit( #[test] fn legacy_non_tty_cmd_emits_output() { + let _guard = legacy_process_test_guard(); let runtime = current_thread_runtime(); runtime.block_on(async move { let cwd = sandbox_cwd(); @@ -167,6 +177,7 @@ fn legacy_non_tty_powershell_emits_output() { let Some(pwsh) = pwsh_path() else { return; }; + let _guard = legacy_process_test_guard(); let runtime = current_thread_runtime(); runtime.block_on(async move { let cwd = sandbox_cwd(); @@ -351,6 +362,7 @@ fn legacy_capture_powershell_emits_output() { let Some(pwsh) = pwsh_path() else { return; }; + let _guard = legacy_process_test_guard(); let cwd = sandbox_cwd(); let codex_home = sandbox_home("legacy-capture-pwsh"); println!("capture pwsh codex_home={}", codex_home.path().display()); @@ -387,6 +399,7 @@ fn legacy_tty_powershell_emits_output_and_accepts_input() { let Some(pwsh) = pwsh_path() else { return; }; + let _guard = legacy_process_test_guard(); let runtime = current_thread_runtime(); runtime.block_on(async move { let cwd = sandbox_cwd(); From 5378cccd8a4762d35f568ef83a0cdf0293a3f834 Mon Sep 17 00:00:00 2001 From: Rasmus Rygaard Date: Fri, 24 Apr 2026 16:27:39 -0700 Subject: [PATCH 07/67] Refactor log DB into LogWriter interface (#19234) ## Why This prepares feedback log capture for a future remote app-server hook sink without changing the current local SQLite upload path. The important boundary is now intentionally small: a log sink is a tracing `Layer` that can also flush entries it has accepted. That keeps the existing SQLite implementation simple while giving the upcoming gRPC sink a place to fit beside it. SQLite and gRPC have different worker/write semantics, so this PR avoids introducing a shared buffered-sink abstraction and instead lets each `LogWriter` own the buffering mechanics it needs. ## What Changed - Added `LogSinkQueueConfig` with the existing local defaults: queue capacity `512`, batch size `128`, and flush interval `2s`. - Added `LogDbLayer::start_with_config(...)` while preserving `LogDbLayer::start(...)` and `log_db::start(...)` defaults. - Introduced the `LogWriter` trait as the minimal shared interface: `tracing_subscriber::Layer` plus `flush()`. - Made `LogDbLayer` implement `LogWriter`. - Kept tracing event formatting inside `LogDbLayer`; it still creates one `LogEntry` per tracing event before queueing it for SQLite. - Kept normal event capture best-effort and non-blocking via bounded `try_send`. ## Behavior Notes This does not change the SQLite schema, retention behavior, `/feedback/upload`, or Sentry upload behavior. Normal log events still drop when the queue is full; explicit `flush()` still waits for queue capacity and receiver processing before returning. ## Verification - `cargo test -p codex-state log_db` - `cargo test -p codex-state` - `just fix -p codex-state` The added tests cover configured batch-size flushing, configured interval flushing, queue-full drops, and the flush barrier semantics. --- codex-rs/state/src/log_db.rs | 292 ++++++++++++++++++++++++++++++++--- 1 file changed, 271 insertions(+), 21 deletions(-) diff --git a/codex-rs/state/src/log_db.rs b/codex-rs/state/src/log_db.rs index c28f762d27..a9da475329 100644 --- a/codex-rs/state/src/log_db.rs +++ b/codex-rs/state/src/log_db.rs @@ -1,8 +1,9 @@ -//! Tracing log export into the state SQLite database. +//! Tracing log export into the local SQLite log database. //! -//! This module provides a `tracing_subscriber::Layer` that captures events and -//! inserts them into the dedicated `logs` SQLite database. The writer runs in a -//! background task and batches inserts to keep logging overhead low. +//! This module provides a `tracing_subscriber::Layer` that captures events, +//! formats each one into a `LogEntry`, and sends entries to a bounded background +//! queue. The background task inserts into the dedicated `logs` SQLite database +//! in batches to keep logging overhead low. //! //! ## Usage //! @@ -18,6 +19,7 @@ //! # } //! ``` +use std::future::Future; use std::sync::OnceLock; use std::time::Duration; use std::time::SystemTime; @@ -45,20 +47,57 @@ use crate::StateRuntime; const LOG_QUEUE_CAPACITY: usize = 512; const LOG_BATCH_SIZE: usize = 128; const LOG_FLUSH_INTERVAL: Duration = Duration::from_secs(2); + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct LogSinkQueueConfig { + pub queue_capacity: usize, + pub batch_size: usize, + pub flush_interval: Duration, +} + +impl Default for LogSinkQueueConfig { + fn default() -> Self { + Self { + queue_capacity: LOG_QUEUE_CAPACITY, + batch_size: LOG_BATCH_SIZE, + flush_interval: LOG_FLUSH_INTERVAL, + } + } +} + +impl LogSinkQueueConfig { + fn normalized(self) -> Self { + Self { + queue_capacity: self.queue_capacity.max(1), + batch_size: self.batch_size.max(1), + flush_interval: if self.flush_interval.is_zero() { + LOG_FLUSH_INTERVAL + } else { + self.flush_interval + }, + } + } +} + +/// A tracing log writer that can flush entries accepted by its queue. +/// +/// Implementations should keep `Layer::on_event` non-blocking for ordinary log +/// events. `flush` should wait for entries accepted before the flush command to +/// be processed by the writer. +pub trait LogWriter: Layer +where + S: tracing::Subscriber + for<'a> LookupSpan<'a>, +{ + fn flush(&self) -> impl Future + Send + '_; +} + pub struct LogDbLayer { sender: mpsc::Sender, process_uuid: String, } pub fn start(state_db: std::sync::Arc) -> LogDbLayer { - let process_uuid = current_process_log_uuid().to_string(); - let (sender, receiver) = mpsc::channel(LOG_QUEUE_CAPACITY); - tokio::spawn(run_inserter(std::sync::Arc::clone(&state_db), receiver)); - - LogDbLayer { - sender, - process_uuid, - } + LogDbLayer::start(state_db) } impl Clone for LogDbLayer { @@ -71,12 +110,33 @@ impl Clone for LogDbLayer { } impl LogDbLayer { + pub fn start(state_db: std::sync::Arc) -> Self { + Self::start_with_config(state_db, LogSinkQueueConfig::default()) + } + + pub fn start_with_config( + state_db: std::sync::Arc, + config: LogSinkQueueConfig, + ) -> Self { + let config = config.normalized(); + let (sender, receiver) = mpsc::channel(config.queue_capacity); + tokio::spawn(run_inserter(state_db, receiver, config)); + Self { + sender, + process_uuid: current_process_log_uuid().to_string(), + } + } + pub async fn flush(&self) { let (tx, rx) = oneshot::channel(); if self.sender.send(LogDbCommand::Flush(tx)).await.is_ok() { let _ = rx.await; } } + + fn try_send(&self, entry: LogEntry) { + let _ = self.sender.try_send(LogDbCommand::Entry(Box::new(entry))); + } } impl Layer for LogDbLayer @@ -154,7 +214,16 @@ where line: metadata.line().map(|line| line as i64), }; - let _ = self.sender.try_send(LogDbCommand::Entry(Box::new(entry))); + self.try_send(entry); + } +} + +impl LogWriter for LogDbLayer +where + S: tracing::Subscriber + for<'a> LookupSpan<'a>, +{ + fn flush(&self) -> impl Future + Send + '_ { + LogDbLayer::flush(self) } } @@ -294,16 +363,17 @@ fn current_process_log_uuid() -> &'static str { async fn run_inserter( state_db: std::sync::Arc, mut receiver: mpsc::Receiver, + config: LogSinkQueueConfig, ) { - let mut buffer = Vec::with_capacity(LOG_BATCH_SIZE); - let mut ticker = tokio::time::interval(LOG_FLUSH_INTERVAL); + let mut buffer = Vec::with_capacity(config.batch_size); + let mut ticker = tokio::time::interval(config.flush_interval); loop { tokio::select! { maybe_command = receiver.recv() => { match maybe_command { Some(LogDbCommand::Entry(entry)) => { buffer.push(*entry); - if buffer.len() >= LOG_BATCH_SIZE { + if buffer.len() >= config.batch_size { flush(&state_db, &mut buffer).await; } } @@ -324,7 +394,7 @@ async fn run_inserter( } } -async fn flush(state_db: &std::sync::Arc, buffer: &mut Vec) { +async fn flush(state_db: &StateRuntime, buffer: &mut Vec) { if buffer.is_empty() { return; } @@ -393,6 +463,45 @@ mod tests { use super::*; + fn temp_codex_home() -> std::path::PathBuf { + std::env::temp_dir().join(format!("codex-state-log-db-{}", Uuid::new_v4())) + } + + async fn wait_for_log_count(runtime: &StateRuntime, expected: usize) -> Vec { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + let rows = runtime + .query_logs(&crate::LogQuery::default()) + .await + .expect("query logs"); + if rows.len() == expected { + return rows; + } + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for {expected} logs; saw {}", + rows.len() + ); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + } + + fn test_entry(message: &str) -> LogEntry { + LogEntry { + ts: 1, + ts_nanos: 2, + level: "INFO".to_string(), + target: "test".to_string(), + message: Some(message.to_string()), + feedback_log_body: Some(message.to_string()), + thread_id: Some("thread-1".to_string()), + process_uuid: Some("process-1".to_string()), + module_path: Some("module".to_string()), + file: Some("file.rs".to_string()), + line: Some(7), + } + } + #[derive(Clone, Default)] struct SharedWriter { bytes: Arc>>, @@ -435,8 +544,7 @@ mod tests { #[tokio::test] async fn sqlite_feedback_logs_match_feedback_formatter_shape() { - let codex_home = - std::env::temp_dir().join(format!("codex-state-log-db-{}", Uuid::new_v4())); + let codex_home = temp_codex_home(); let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) .await .expect("initialize runtime"); @@ -494,8 +602,7 @@ mod tests { #[tokio::test] async fn flush_persists_logs_for_query() { - let codex_home = - std::env::temp_dir().join(format!("codex-state-log-db-{}", Uuid::new_v4())); + let codex_home = temp_codex_home(); let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) .await .expect("initialize runtime"); @@ -523,4 +630,147 @@ mod tests { let _ = tokio::fs::remove_dir_all(codex_home).await; } + + #[tokio::test] + async fn configured_batch_size_flushes_without_explicit_flush() { + let codex_home = temp_codex_home(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("initialize runtime"); + let layer = LogDbLayer::start_with_config( + runtime.clone(), + LogSinkQueueConfig { + queue_capacity: 8, + batch_size: 2, + flush_interval: std::time::Duration::from_secs(60), + }, + ); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + let guard = tracing_subscriber::registry() + .with( + layer + .clone() + .with_filter(Targets::new().with_default(tracing::Level::TRACE)), + ) + .set_default(); + + tracing::info!("first-batch-log"); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + assert_eq!( + runtime + .query_logs(&crate::LogQuery::default()) + .await + .expect("query logs before batch fills") + .len(), + 0 + ); + + tracing::info!("second-batch-log"); + let after_batch = wait_for_log_count(&runtime, /*expected*/ 2).await; + drop(guard); + + assert_eq!( + after_batch + .iter() + .map(|row| row.message.as_deref()) + .collect::>(), + vec![Some("first-batch-log"), Some("second-batch-log")] + ); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } + + #[tokio::test] + async fn configured_flush_interval_persists_buffered_logs() { + let codex_home = temp_codex_home(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("initialize runtime"); + let layer = LogDbLayer::start_with_config( + runtime.clone(), + LogSinkQueueConfig { + queue_capacity: 8, + batch_size: 128, + flush_interval: std::time::Duration::from_millis(10), + }, + ); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + + let guard = tracing_subscriber::registry() + .with( + layer + .clone() + .with_filter(Targets::new().with_default(tracing::Level::TRACE)), + ) + .set_default(); + + tracing::info!("interval-log"); + let after_interval = wait_for_log_count(&runtime, /*expected*/ 1).await; + drop(guard); + + assert_eq!(after_interval[0].message.as_deref(), Some("interval-log")); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } + + #[tokio::test] + async fn event_queue_drops_new_entries_when_full() { + let (sender, mut receiver) = mpsc::channel(1); + let layer = LogDbLayer { + sender, + process_uuid: "process-1".to_string(), + }; + + layer.try_send(test_entry("first-queued-log")); + layer.try_send(test_entry("dropped-log")); + + match receiver.try_recv().expect("first entry queued") { + LogDbCommand::Entry(entry) => { + assert_eq!(entry.message.as_deref(), Some("first-queued-log")); + } + LogDbCommand::Flush(_) => panic!("expected queued entry"), + } + assert!(receiver.try_recv().is_err()); + } + + #[tokio::test] + async fn flush_waits_for_queue_capacity_and_receiver_processing() { + let (sender, mut receiver) = mpsc::channel(1); + let layer = LogDbLayer { + sender, + process_uuid: "process-1".to_string(), + }; + + layer.try_send(test_entry("queued-before-flush")); + let mut flush_task = tokio::spawn({ + let layer = layer.clone(); + async move { + layer.flush().await; + } + }); + + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + assert!(!flush_task.is_finished()); + + match receiver.recv().await.expect("queued entry") { + LogDbCommand::Entry(entry) => { + assert_eq!(entry.message.as_deref(), Some("queued-before-flush")); + } + LogDbCommand::Flush(_) => panic!("expected queued entry"), + } + + match receiver.recv().await.expect("flush command") { + LogDbCommand::Flush(reply) => { + assert!(!flush_task.is_finished()); + let _ = reply.send(()); + } + LogDbCommand::Entry(_) => panic!("expected flush command"), + } + + tokio::time::timeout(std::time::Duration::from_secs(1), &mut flush_task) + .await + .expect("flush task completes") + .expect("flush task succeeds"); + } } From d19de6d15039bd0e15cad52a6ec2c915ff30795f Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Fri, 24 Apr 2026 17:05:22 -0700 Subject: [PATCH 08/67] fix: Bedrock GPT-5.4 reasoning levels (#19461) ## Why When using the Amazon Bedrock provider with `openai.gpt-5.4-cmb`, the model picker allowed `xhigh` because the CMB catalog entry was derived from the bundled `gpt-5.4` reasoning metadata. Bedrock rejects that effort level, causing the request to fail before the turn can run: ```text {"error":{"code":"validation_error","message":"Failed to deserialize the JSON body into the target type: Invalid 'reasoning': Invalid 'effort': unknown variant `xhigh`, expected one of `high`, `low`, `medium`, `minimal` at line 1 column 77239","param":null,"type":"invalid_request_error"}} ``` ## What Changed - Replace the runtime lookup of bundled `gpt-5.4` metadata for `openai.gpt-5.4-cmb` with an explicit Bedrock CMB `ModelInfo` entry. - Advertise only the Bedrock-supported CMB reasoning levels: `minimal`, `low`, `medium`, and `high`. - Keep the existing GPT OSS Bedrock model metadata and reasoning levels unchanged. - Add catalog coverage for the hardcoded CMB metadata and Bedrock-compatible reasoning level list. --- .../src/amazon_bedrock/catalog.rs | 86 ++++++++++++------- 1 file changed, 54 insertions(+), 32 deletions(-) diff --git a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs index c6fc6aa07e..aa9e8bdead 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/catalog.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/catalog.rs @@ -1,6 +1,6 @@ -use codex_models_manager::bundled_models_response; -use codex_models_manager::model_info::model_info_from_slug; +use codex_models_manager::model_info::BASE_INSTRUCTIONS; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::Verbosity; use codex_protocol::openai_models::ApplyPatchToolType; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; @@ -13,19 +13,20 @@ use codex_protocol::openai_models::TruncationPolicyConfig; use codex_protocol::openai_models::WebSearchToolType; const GPT_OSS_CONTEXT_WINDOW: i64 = 128_000; +const GPT_5_4_CONTEXT_WINDOW: i64 = 272_000; +const GPT_5_4_MAX_CONTEXT_WINDOW: i64 = 1_000_000; const GPT_5_4_CMB_MODEL_ID: &str = "openai.gpt-5.4-cmb"; -const GPT_5_4_MODEL_ID: &str = "gpt-5.4"; pub(crate) fn static_model_catalog() -> ModelsResponse { ModelsResponse { models: vec![ gpt_5_4_cmb_bedrock_model(/*priority*/ 0), - bedrock_model( + bedrock_oss_model( "openai.gpt-oss-120b", "GPT OSS 120B on Bedrock", /*priority*/ 1, ), - bedrock_model( + bedrock_oss_model( "openai.gpt-oss-20b", "GPT OSS 20B on Bedrock", /*priority*/ 2, @@ -35,28 +36,42 @@ pub(crate) fn static_model_catalog() -> ModelsResponse { } fn gpt_5_4_cmb_bedrock_model(priority: i32) -> ModelInfo { - let mut model = bundled_gpt_5_4_model(); - - model.slug = GPT_5_4_CMB_MODEL_ID.to_string(); - model.priority = priority; - model.apply_patch_tool_type = Some(ApplyPatchToolType::Function); - model -} - -fn bundled_gpt_5_4_model() -> ModelInfo { - if let Ok(response) = bundled_models_response() - && let Some(model) = response - .models - .into_iter() - .find(|model| model.slug == GPT_5_4_MODEL_ID) - { - return model; + ModelInfo { + slug: GPT_5_4_CMB_MODEL_ID.to_string(), + display_name: "gpt-5.4".to_string(), + description: Some("Strong model for everyday coding.".to_string()), + default_reasoning_level: Some(ReasoningEffort::Medium), + supported_reasoning_levels: gpt_5_4_cmb_reasoning_levels(), + shell_type: ConfigShellToolType::ShellCommand, + visibility: ModelVisibility::List, + supported_in_api: true, + priority, + additional_speed_tiers: vec!["fast".to_string()], + availability_nux: None, + upgrade: None, + base_instructions: BASE_INSTRUCTIONS.to_string(), + model_messages: None, + supports_reasoning_summaries: true, + default_reasoning_summary: ReasoningSummary::None, + support_verbosity: true, + default_verbosity: Some(Verbosity::Medium), + apply_patch_tool_type: Some(ApplyPatchToolType::Function), + web_search_tool_type: WebSearchToolType::TextAndImage, + truncation_policy: TruncationPolicyConfig::tokens(/*limit*/ 10_000), + supports_parallel_tool_calls: true, + supports_image_detail_original: true, + context_window: Some(GPT_5_4_CONTEXT_WINDOW), + max_context_window: Some(GPT_5_4_MAX_CONTEXT_WINDOW), + auto_compact_token_limit: None, + effective_context_window_percent: 95, + experimental_supported_tools: Vec::new(), + input_modalities: vec![InputModality::Text, InputModality::Image], + used_fallback_model_metadata: false, + supports_search_tool: true, } - - model_info_from_slug(GPT_5_4_MODEL_ID) } -fn bedrock_model(slug: &str, display_name: &str, priority: i32) -> ModelInfo { +fn bedrock_oss_model(slug: &str, display_name: &str, priority: i32) -> ModelInfo { ModelInfo { slug: slug.to_string(), display_name: display_name.to_string(), @@ -74,7 +89,7 @@ fn bedrock_model(slug: &str, display_name: &str, priority: i32) -> ModelInfo { additional_speed_tiers: Vec::new(), availability_nux: None, upgrade: None, - base_instructions: codex_models_manager::model_info::BASE_INSTRUCTIONS.to_string(), + base_instructions: BASE_INSTRUCTIONS.to_string(), model_messages: None, supports_reasoning_summaries: true, default_reasoning_summary: ReasoningSummary::None, @@ -96,6 +111,15 @@ fn bedrock_model(slug: &str, display_name: &str, priority: i32) -> ModelInfo { } } +fn gpt_5_4_cmb_reasoning_levels() -> Vec { + vec![ + reasoning_effort_preset(ReasoningEffort::Minimal), + reasoning_effort_preset(ReasoningEffort::Low), + reasoning_effort_preset(ReasoningEffort::Medium), + reasoning_effort_preset(ReasoningEffort::High), + ] +} + fn reasoning_effort_preset(effort: ReasoningEffort) -> ReasoningEffortPreset { ReasoningEffortPreset { effort, @@ -128,19 +152,17 @@ mod tests { } #[test] - fn gpt_5_4_cmb_uses_gpt_5_4_spec() { + fn gpt_5_4_cmb_advertises_only_bedrock_supported_reasoning_levels() { let catalog = static_model_catalog(); let cmb_model = catalog .models .iter() .find(|model| model.slug == GPT_5_4_CMB_MODEL_ID) .expect("Bedrock catalog should include GPT-5.4 CMB"); - let mut gpt_5_4_model = bundled_gpt_5_4_model(); - gpt_5_4_model.slug = GPT_5_4_CMB_MODEL_ID.to_string(); - gpt_5_4_model.priority = cmb_model.priority; - gpt_5_4_model.apply_patch_tool_type = Some(ApplyPatchToolType::Function); - - assert_eq!(*cmb_model, gpt_5_4_model); + assert_eq!( + cmb_model.supported_reasoning_levels, + gpt_5_4_cmb_reasoning_levels() + ); } } From 789f387982c51e8032766f91d4b026f4c50b0ff8 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Fri, 24 Apr 2026 17:16:58 -0700 Subject: [PATCH 09/67] permissions: remove legacy read-only access modes (#19449) ## Why `ReadOnlyAccess` was a transitional legacy shape on `SandboxPolicy`: `FullAccess` meant the historical read-only/workspace-write modes could read the full filesystem, while `Restricted` tried to carry partial readable roots. The partial-read model now belongs in `FileSystemSandboxPolicy` and `PermissionProfile`, so keeping it on `SandboxPolicy` makes every legacy projection reintroduce lossy read-root bookkeeping and creates unnecessary noise in the rest of the permissions migration. This PR makes the legacy policy model narrower and explicit: `SandboxPolicy::ReadOnly` and `SandboxPolicy::WorkspaceWrite` represent the old full-read sandbox modes only. Split readable roots, deny-read globs, and platform-default/minimal read behavior stay in the runtime permissions model. ## What changed - Removes `ReadOnlyAccess` from `codex_protocol::protocol::SandboxPolicy`, including the generated `access` and `readOnlyAccess` API fields. - Updates legacy policy/profile conversions so restricted filesystem reads are represented only by `FileSystemSandboxPolicy` / `PermissionProfile` entries. - Keeps app-server v2 compatible with legacy `fullAccess` read-access payloads by accepting and ignoring that no-op shape, while rejecting legacy `restricted` read-access payloads instead of silently widening them to full-read legacy policies. - Carries Windows sandbox platform-default read behavior with an explicit override flag instead of depending on `ReadOnlyAccess::Restricted`. - Refreshes generated app-server schema/types and updates tests/docs for the simplified legacy policy shape. ## Verification - `cargo check -p codex-app-server-protocol --tests` - `cargo check -p codex-windows-sandbox --tests` - `cargo test -p codex-app-server-protocol sandbox_policy_` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/19449). * #19395 * #19394 * #19393 * #19392 * #19391 * __->__ #19449 --- .../schema/json/ClientRequest.json | 67 ----- .../codex_app_server_protocol.schemas.json | 67 ----- .../codex_app_server_protocol.v2.schemas.json | 67 ----- .../schema/json/v2/CommandExecParams.json | 67 ----- .../schema/json/v2/ThreadForkResponse.json | 67 ----- .../schema/json/v2/ThreadResumeResponse.json | 67 ----- .../schema/json/v2/ThreadStartResponse.json | 67 ----- .../schema/json/v2/TurnStartParams.json | 67 ----- .../schema/typescript/v2/ReadOnlyAccess.ts | 6 - .../schema/typescript/v2/SandboxPolicy.ts | 3 +- .../schema/typescript/v2/index.ts | 1 - .../app-server-protocol/src/protocol/v2.rs | 263 ++++++++++-------- codex-rs/app-server-test-client/src/lib.rs | 4 - codex-rs/app-server/src/command_exec.rs | 3 - .../app-server/tests/suite/v2/turn_start.rs | 1 - .../tests/suite/v2/turn_start_zsh_fork.rs | 1 - codex-rs/config/src/config_requirements.rs | 2 - codex-rs/config/src/config_toml.rs | 2 - codex-rs/core/README.md | 17 +- codex-rs/core/src/config/config_tests.rs | 15 - codex-rs/core/src/config_loader/tests.rs | 1 - .../context/permissions_instructions_tests.rs | 1 - codex-rs/core/src/exec.rs | 27 +- codex-rs/core/src/exec_tests.rs | 25 +- codex-rs/core/src/memories/phase2.rs | 1 - codex-rs/core/src/safety_tests.rs | 5 - codex-rs/core/src/session/tests.rs | 13 - .../src/tools/handlers/apply_patch_tests.rs | 2 - .../runtimes/shell/unix_escalation_tests.rs | 2 - codex-rs/core/tests/common/zsh_fork.rs | 1 - codex-rs/core/tests/suite/apply_patch_cli.rs | 2 - codex-rs/core/tests/suite/approvals.rs | 4 - codex-rs/core/tests/suite/hooks.rs | 2 - .../core/tests/suite/permissions_messages.rs | 1 - codex-rs/core/tests/suite/prompt_caching.rs | 2 - codex-rs/core/tests/suite/remote_env.rs | 49 ++-- .../core/tests/suite/request_permissions.rs | 1 - .../tests/suite/request_permissions_tool.rs | 1 - .../suite/responses_api_proxy_headers.rs | 1 - codex-rs/exec-server/src/fs_sandbox.rs | 10 - .../src/event_processor_with_human_output.rs | 1 - codex-rs/exec/src/lib_tests.rs | 1 - codex-rs/exec/tests/suite/sandbox.rs | 3 - codex-rs/linux-sandbox/src/bwrap.rs | 52 ++-- .../linux-sandbox/src/linux_run_main_tests.rs | 3 - .../linux-sandbox/tests/suite/landlock.rs | 5 - codex-rs/protocol/src/permissions.rs | 192 +++---------- codex-rs/protocol/src/protocol.rs | 247 +--------------- codex-rs/sandboxing/src/manager_tests.rs | 2 - codex-rs/sandboxing/src/policy_transforms.rs | 38 +-- .../sandboxing/src/policy_transforms_tests.rs | 11 +- ...estricted_read_only_platform_defaults.sbpl | 2 +- codex-rs/sandboxing/src/seatbelt_tests.rs | 46 --- codex-rs/tui/src/chatwidget/tests.rs | 1 - .../tui/src/chatwidget/tests/permissions.rs | 2 - codex-rs/tui/src/status/tests.rs | 2 - .../sandbox-summary/src/sandbox_summary.rs | 3 - codex-rs/windows-sandbox-rs/src/allow.rs | 6 - .../windows-sandbox-rs/src/elevated_impl.rs | 4 +- codex-rs/windows-sandbox-rs/src/identity.rs | 3 + codex-rs/windows-sandbox-rs/src/lib.rs | 1 - .../src/setup_orchestrator.rs | 158 +---------- codex-rs/windows-sandbox-rs/src/spawn_prep.rs | 2 +- 63 files changed, 284 insertions(+), 1506 deletions(-) delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ReadOnlyAccess.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index d7631e1572..f895d3fe77 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2126,53 +2126,6 @@ ], "type": "object" }, - "ReadOnlyAccess": { - "oneOf": [ - { - "properties": { - "includePlatformDefaults": { - "default": true, - "type": "boolean" - }, - "readableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RestrictedReadOnlyAccess", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "fullAccess" - ], - "title": "FullAccessReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "FullAccessReadOnlyAccess", - "type": "object" - } - ] - }, "RealtimeOutputModality": { "enum": [ "text", @@ -3056,16 +3009,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -3122,16 +3065,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" 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 cdc9da679e..0c76232d91 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 @@ -12228,53 +12228,6 @@ "title": "RawResponseItemCompletedNotification", "type": "object" }, - "ReadOnlyAccess": { - "oneOf": [ - { - "properties": { - "includePlatformDefaults": { - "default": true, - "type": "boolean" - }, - "readableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": "array" - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RestrictedReadOnlyAccess", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "fullAccess" - ], - "title": "FullAccessReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "FullAccessReadOnlyAccess", - "type": "object" - } - ] - }, "RealtimeConversationVersion": { "enum": [ "v1", @@ -13483,16 +13436,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/v2/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -13549,16 +13492,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/v2/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" 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 04c91b5a28..55f33badd0 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 @@ -8942,53 +8942,6 @@ "title": "RawResponseItemCompletedNotification", "type": "object" }, - "ReadOnlyAccess": { - "oneOf": [ - { - "properties": { - "includePlatformDefaults": { - "default": true, - "type": "boolean" - }, - "readableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RestrictedReadOnlyAccess", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "fullAccess" - ], - "title": "FullAccessReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "FullAccessReadOnlyAccess", - "type": "object" - } - ] - }, "RealtimeConversationVersion": { "enum": [ "v1", @@ -10197,16 +10150,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -10263,16 +10206,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" diff --git a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json index 6ba2fc0db4..b85a0e7911 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -374,53 +374,6 @@ ], "type": "object" }, - "ReadOnlyAccess": { - "oneOf": [ - { - "properties": { - "includePlatformDefaults": { - "default": true, - "type": "boolean" - }, - "readableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RestrictedReadOnlyAccess", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "fullAccess" - ], - "title": "FullAccessReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "FullAccessReadOnlyAccess", - "type": "object" - } - ] - }, "SandboxPolicy": { "oneOf": [ { @@ -441,16 +394,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -507,16 +450,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 281650bb3a..a2f2490a0b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -1028,53 +1028,6 @@ ], "type": "object" }, - "ReadOnlyAccess": { - "oneOf": [ - { - "properties": { - "includePlatformDefaults": { - "default": true, - "type": "boolean" - }, - "readableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RestrictedReadOnlyAccess", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "fullAccess" - ], - "title": "FullAccessReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "FullAccessReadOnlyAccess", - "type": "object" - } - ] - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -1107,16 +1060,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -1173,16 +1116,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 573cbe92d0..516627576e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -1028,53 +1028,6 @@ ], "type": "object" }, - "ReadOnlyAccess": { - "oneOf": [ - { - "properties": { - "includePlatformDefaults": { - "default": true, - "type": "boolean" - }, - "readableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RestrictedReadOnlyAccess", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "fullAccess" - ], - "title": "FullAccessReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "FullAccessReadOnlyAccess", - "type": "object" - } - ] - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -1107,16 +1060,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -1173,16 +1116,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 1de06c6039..f773c0be69 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -1028,53 +1028,6 @@ ], "type": "object" }, - "ReadOnlyAccess": { - "oneOf": [ - { - "properties": { - "includePlatformDefaults": { - "default": true, - "type": "boolean" - }, - "readableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RestrictedReadOnlyAccess", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "fullAccess" - ], - "title": "FullAccessReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "FullAccessReadOnlyAccess", - "type": "object" - } - ] - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -1107,16 +1060,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -1173,16 +1116,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 245c57886e..559698100f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -462,53 +462,6 @@ ], "type": "string" }, - "ReadOnlyAccess": { - "oneOf": [ - { - "properties": { - "includePlatformDefaults": { - "default": true, - "type": "boolean" - }, - "readableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "RestrictedReadOnlyAccess", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "fullAccess" - ], - "title": "FullAccessReadOnlyAccessType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "FullAccessReadOnlyAccess", - "type": "object" - } - ] - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -561,16 +514,6 @@ }, { "properties": { - "access": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "networkAccess": { "default": false, "type": "boolean" @@ -627,16 +570,6 @@ "default": false, "type": "boolean" }, - "readOnlyAccess": { - "allOf": [ - { - "$ref": "#/definitions/ReadOnlyAccess" - } - ], - "default": { - "type": "fullAccess" - } - }, "type": { "enum": [ "workspaceWrite" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ReadOnlyAccess.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ReadOnlyAccess.ts deleted file mode 100644 index 78fa04ff37..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ReadOnlyAccess.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; - -export type ReadOnlyAccess = { "type": "restricted", includePlatformDefaults: boolean, readableRoots: Array, } | { "type": "fullAccess" }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts index c6780648cf..5575701ff2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts @@ -3,6 +3,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { NetworkAccess } from "./NetworkAccess"; -import type { ReadOnlyAccess } from "./ReadOnlyAccess"; -export type SandboxPolicy = { "type": "dangerFullAccess" } | { "type": "readOnly", access: ReadOnlyAccess, networkAccess: boolean, } | { "type": "externalSandbox", networkAccess: NetworkAccess, } | { "type": "workspaceWrite", writableRoots: Array, readOnlyAccess: ReadOnlyAccess, networkAccess: boolean, excludeTmpdirEnvVar: boolean, excludeSlashTmp: boolean, }; +export type SandboxPolicy = { "type": "dangerFullAccess" } | { "type": "readOnly", networkAccess: boolean, } | { "type": "externalSandbox", networkAccess: NetworkAccess, } | { "type": "workspaceWrite", writableRoots: Array, networkAccess: boolean, excludeTmpdirEnvVar: boolean, excludeSlashTmp: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index be747508ac..59c4fa6734 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -276,7 +276,6 @@ export type { RateLimitReachedType } from "./RateLimitReachedType"; export type { RateLimitSnapshot } from "./RateLimitSnapshot"; export type { RateLimitWindow } from "./RateLimitWindow"; export type { RawResponseItemCompletedNotification } from "./RawResponseItemCompletedNotification"; -export type { ReadOnlyAccess } from "./ReadOnlyAccess"; export type { ReasoningEffortOption } from "./ReasoningEffortOption"; export type { ReasoningSummaryPartAddedNotification } from "./ReasoningSummaryPartAddedNotification"; export type { ReasoningSummaryTextDeltaNotification } from "./ReasoningSummaryTextDeltaNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 5936b3e142..505102e128 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -83,7 +83,6 @@ use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; use codex_protocol::protocol::RateLimitReachedType as CoreRateLimitReachedType; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; -use codex_protocol::protocol::ReadOnlyAccess as CoreReadOnlyAccess; use codex_protocol::protocol::RealtimeAudioFrame as CoreRealtimeAudioFrame; use codex_protocol::protocol::RealtimeConversationVersion; use codex_protocol::protocol::RealtimeOutputModality; @@ -808,10 +807,6 @@ const fn default_enabled() -> bool { true } -const fn default_include_platform_defaults() -> bool { - true -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] #[serde(rename_all = "snake_case")] #[ts(export_to = "v2/")] @@ -1719,54 +1714,7 @@ pub enum NetworkAccess { Enabled, } -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ReadOnlyAccess { - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Restricted { - #[serde(default = "default_include_platform_defaults")] - include_platform_defaults: bool, - #[serde(default)] - readable_roots: Vec, - }, - #[default] - FullAccess, -} - -impl ReadOnlyAccess { - pub fn to_core(&self) -> CoreReadOnlyAccess { - match self { - ReadOnlyAccess::Restricted { - include_platform_defaults, - readable_roots, - } => CoreReadOnlyAccess::Restricted { - include_platform_defaults: *include_platform_defaults, - readable_roots: readable_roots.clone(), - }, - ReadOnlyAccess::FullAccess => CoreReadOnlyAccess::FullAccess, - } - } -} - -impl From for ReadOnlyAccess { - fn from(value: CoreReadOnlyAccess) -> Self { - match value { - CoreReadOnlyAccess::Restricted { - include_platform_defaults, - readable_roots, - } => ReadOnlyAccess::Restricted { - include_platform_defaults, - readable_roots, - }, - CoreReadOnlyAccess::FullAccess => ReadOnlyAccess::FullAccess, - } - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[derive(Serialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] #[ts(export_to = "v2/")] @@ -1775,8 +1723,6 @@ pub enum SandboxPolicy { #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] ReadOnly { - #[serde(default)] - access: ReadOnlyAccess, #[serde(default)] network_access: bool, }, @@ -1792,7 +1738,36 @@ pub enum SandboxPolicy { #[serde(default)] writable_roots: Vec, #[serde(default)] - read_only_access: ReadOnlyAccess, + network_access: bool, + #[serde(default)] + exclude_tmpdir_env_var: bool, + #[serde(default)] + exclude_slash_tmp: bool, + }, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +enum SandboxPolicyDeserialize { + DangerFullAccess, + #[serde(rename_all = "camelCase")] + ReadOnly { + #[serde(default)] + network_access: bool, + #[serde(default)] + access: Option, + }, + #[serde(rename_all = "camelCase")] + ExternalSandbox { + #[serde(default)] + network_access: NetworkAccess, + }, + #[serde(rename_all = "camelCase")] + WorkspaceWrite { + #[serde(default)] + writable_roots: Vec, + #[serde(default)] + read_only_access: Option, #[serde(default)] network_access: bool, #[serde(default)] @@ -1802,19 +1777,68 @@ pub enum SandboxPolicy { }, } +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +enum LegacyReadOnlyAccess { + FullAccess, + Restricted, +} + +impl<'de> Deserialize<'de> for SandboxPolicy { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + match SandboxPolicyDeserialize::deserialize(deserializer)? { + SandboxPolicyDeserialize::DangerFullAccess => Ok(SandboxPolicy::DangerFullAccess), + SandboxPolicyDeserialize::ReadOnly { + network_access, + access, + } => { + if matches!(access, Some(LegacyReadOnlyAccess::Restricted)) { + return Err(serde::de::Error::custom( + "readOnly.access is no longer supported; use permissionProfile for restricted reads", + )); + } + Ok(SandboxPolicy::ReadOnly { network_access }) + } + SandboxPolicyDeserialize::ExternalSandbox { network_access } => { + Ok(SandboxPolicy::ExternalSandbox { network_access }) + } + SandboxPolicyDeserialize::WorkspaceWrite { + writable_roots, + read_only_access, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + } => { + if matches!(read_only_access, Some(LegacyReadOnlyAccess::Restricted)) { + return Err(serde::de::Error::custom( + "workspaceWrite.readOnlyAccess is no longer supported; use permissionProfile for restricted reads", + )); + } + Ok(SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + }) + } + } + } +} + impl SandboxPolicy { pub fn to_core(&self) -> codex_protocol::protocol::SandboxPolicy { match self { SandboxPolicy::DangerFullAccess => { codex_protocol::protocol::SandboxPolicy::DangerFullAccess } - SandboxPolicy::ReadOnly { - access, - network_access, - } => codex_protocol::protocol::SandboxPolicy::ReadOnly { - access: access.to_core(), - network_access: *network_access, - }, + SandboxPolicy::ReadOnly { network_access } => { + codex_protocol::protocol::SandboxPolicy::ReadOnly { + network_access: *network_access, + } + } SandboxPolicy::ExternalSandbox { network_access } => { codex_protocol::protocol::SandboxPolicy::ExternalSandbox { network_access: match network_access { @@ -1825,13 +1849,11 @@ impl SandboxPolicy { } SandboxPolicy::WorkspaceWrite { writable_roots, - read_only_access, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, } => codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { writable_roots: writable_roots.clone(), - read_only_access: read_only_access.to_core(), network_access: *network_access, exclude_tmpdir_env_var: *exclude_tmpdir_env_var, exclude_slash_tmp: *exclude_slash_tmp, @@ -1846,13 +1868,9 @@ impl From for SandboxPolicy { codex_protocol::protocol::SandboxPolicy::DangerFullAccess => { SandboxPolicy::DangerFullAccess } - codex_protocol::protocol::SandboxPolicy::ReadOnly { - access, - network_access, - } => SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::from(access), - network_access, - }, + codex_protocol::protocol::SandboxPolicy::ReadOnly { network_access } => { + SandboxPolicy::ReadOnly { network_access } + } codex_protocol::protocol::SandboxPolicy::ExternalSandbox { network_access } => { SandboxPolicy::ExternalSandbox { network_access: match network_access { @@ -1863,13 +1881,11 @@ impl From for SandboxPolicy { } codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { writable_roots, - read_only_access, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, } => SandboxPolicy::WorkspaceWrite { writable_roots, - read_only_access: ReadOnlyAccess::from(read_only_access), network_access, exclude_tmpdir_env_var, exclude_slash_tmp, @@ -7578,7 +7594,6 @@ mod tests { use codex_protocol::items::WebSearchItem; use codex_protocol::models::WebSearchAction as CoreWebSearchAction; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; - use codex_protocol::protocol::ReadOnlyAccess as CoreReadOnlyAccess; use codex_protocol::user_input::UserInput as CoreUserInput; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; @@ -8784,13 +8799,8 @@ mod tests { } #[test] - fn sandbox_policy_round_trips_read_only_access() { - let readable_root = test_absolute_path(); + fn sandbox_policy_round_trips_read_only_network_access() { let v2_policy = SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: vec![readable_root.clone()], - }, network_access: true, }; @@ -8798,10 +8808,6 @@ mod tests { assert_eq!( core_policy, codex_protocol::protocol::SandboxPolicy::ReadOnly { - access: CoreReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: vec![readable_root], - }, network_access: true, } ); @@ -9425,14 +9431,9 @@ mod tests { } #[test] - fn sandbox_policy_round_trips_workspace_write_read_only_access() { - let readable_root = test_absolute_path(); + fn sandbox_policy_round_trips_workspace_write_access() { let v2_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: vec![readable_root.clone()], - }, network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -9443,10 +9444,6 @@ mod tests { core_policy, codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: CoreReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: vec![readable_root], - }, network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -9458,40 +9455,78 @@ mod tests { } #[test] - fn sandbox_policy_deserializes_legacy_read_only_without_access_field() { - let policy: SandboxPolicy = serde_json::from_value(json!({ - "type": "readOnly" + fn sandbox_policy_deserializes_legacy_read_only_full_access_field() { + let policy = serde_json::from_value::(json!({ + "type": "readOnly", + "access": { + "type": "fullAccess" + }, + "networkAccess": true })) - .expect("read-only policy should deserialize"); + .expect("read-only policy should ignore legacy fullAccess field"); assert_eq!( policy, SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, - network_access: false, + network_access: true } ); } #[test] - fn sandbox_policy_deserializes_legacy_workspace_write_without_read_only_access_field() { - let policy: SandboxPolicy = serde_json::from_value(json!({ + fn sandbox_policy_deserializes_legacy_workspace_write_full_access_field() { + let writable_root = absolute_path("/workspace"); + let policy = serde_json::from_value::(json!({ + "type": "workspaceWrite", + "writableRoots": [writable_root], + "readOnlyAccess": { + "type": "fullAccess" + }, + "networkAccess": true, + "excludeTmpdirEnvVar": true, + "excludeSlashTmp": true + })) + .expect("workspace-write policy should ignore legacy fullAccess field"); + assert_eq!( + policy, + SandboxPolicy::WorkspaceWrite { + writable_roots: vec![absolute_path("/workspace")], + network_access: true, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + } + ); + } + + #[test] + fn sandbox_policy_rejects_legacy_read_only_restricted_access_field() { + let err = serde_json::from_value::(json!({ + "type": "readOnly", + "access": { + "type": "restricted", + "includePlatformDefaults": false, + "readableRoots": [] + } + })) + .expect_err("read-only policy should reject removed restricted access field"); + assert!(err.to_string().contains("readOnly.access")); + } + + #[test] + fn sandbox_policy_rejects_legacy_workspace_write_restricted_read_access_field() { + let err = serde_json::from_value::(json!({ "type": "workspaceWrite", "writableRoots": [], + "readOnlyAccess": { + "type": "restricted", + "includePlatformDefaults": false, + "readableRoots": [] + }, "networkAccess": false, "excludeTmpdirEnvVar": false, "excludeSlashTmp": false })) - .expect("workspace-write policy should deserialize"); - assert_eq!( - policy, - SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - read_only_access: ReadOnlyAccess::FullAccess, - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - } - ); + .expect_err("workspace-write policy should reject removed restricted readOnlyAccess field"); + assert!(err.to_string().contains("workspaceWrite.readOnlyAccess")); } #[test] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index cf28cb151c..2a3cea273b 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -48,7 +48,6 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; -use codex_app_server_protocol::ReadOnlyAccess; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy; use codex_app_server_protocol::ServerNotification; @@ -743,7 +742,6 @@ async fn trigger_zsh_fork_multi_cmd_approval( }; turn_params.approval_policy = Some(AskForApproval::OnRequest); turn_params.sandbox_policy = Some(SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, network_access: false, }); @@ -885,7 +883,6 @@ async fn trigger_cmd_approval( experimental_api: true, approval_policy: Some(AskForApproval::OnRequest), sandbox_policy: Some(SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, network_access: false, }), dynamic_tools, @@ -912,7 +909,6 @@ async fn trigger_patch_approval( experimental_api: true, approval_policy: Some(AskForApproval::OnRequest), sandbox_policy: Some(SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, network_access: false, }), dynamic_tools, diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index b72c84e906..8004e282e6 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -712,7 +712,6 @@ mod tests { use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; - use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -731,7 +730,6 @@ mod tests { fn windows_sandbox_exec_request() -> ExecRequest { let sandbox_policy = SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, network_access: false, }; ExecRequest::new( @@ -837,7 +835,6 @@ mod tests { request_id: codex_app_server_protocol::RequestId::Integer(100), }; let sandbox_policy = SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, network_access: false, }; 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 04e6ede0c5..d41ca2610b 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1895,7 +1895,6 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { approvals_reviewer: None, sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { writable_roots: vec![first_cwd.try_into()?], - read_only_access: codex_app_server_protocol::ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index eda24358ce..31247418e5 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -536,7 +536,6 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() approval_policy: Some(codex_app_server_protocol::AskForApproval::UnlessTrusted), sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { writable_roots: vec![workspace.clone().try_into()?], - read_only_access: codex_app_server_protocol::ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 56ff26f907..ef0602ae24 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -1961,7 +1961,6 @@ allowed_approvals_reviewers = ["user"] .sandbox_policy .can_set(&SandboxPolicy::WorkspaceWrite { writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -2070,7 +2069,6 @@ allowed_approvals_reviewers = ["user"] .sandbox_policy .can_set(&SandboxPolicy::WorkspaceWrite { writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index f0de00192f..67d68fa04e 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -49,7 +49,6 @@ use codex_protocol::config_types::WebSearchToolConfig; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path::normalize_for_path_comparison; @@ -685,7 +684,6 @@ impl ConfigToml { exclude_slash_tmp, }) => SandboxPolicy::WorkspaceWrite { writable_roots: writable_roots.clone(), - read_only_access: ReadOnlyAccess::FullAccess, network_access: *network_access, exclude_tmpdir_env_var: *exclude_tmpdir_env_var, exclude_slash_tmp: *exclude_slash_tmp, diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index 2e311790d9..be222a1673 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -51,23 +51,18 @@ sandboxed shell commands that would enter the bubblewrap path before invoking ### Windows Legacy `SandboxPolicy` / `sandbox_mode` configs are still supported on -Windows. - -The elevated setup/runner backend supports legacy `ReadOnlyAccess::Restricted` -for `read-only` and `workspace-write` policies. Restricted read access honors -explicit readable roots plus the command `cwd`, and keeps writable roots -readable when `workspace-write` is used. - -When `include_platform_defaults = true`, the elevated Windows backend adds -backend-managed system read roots required for basic execution, such as -`C:\Windows`, `C:\Program Files`, `C:\Program Files (x86)`, and -`C:\ProgramData`. When it is `false`, those extra system roots are omitted. +Windows. Legacy `read-only` and `workspace-write` policies imply full +filesystem read access; exact readable roots are represented by split +filesystem policies instead. The elevated Windows sandbox also supports: - legacy `ReadOnly` and `WorkspaceWrite` behavior - split filesystem policies that need exact readable roots, exact writable roots, or extra read-only carveouts under writable roots +- backend-managed system read roots required for basic execution, such as + `C:\Windows`, `C:\Program Files`, `C:\Program Files (x86)`, and + `C:\ProgramData`, when a split filesystem policy requests platform defaults The unelevated restricted-token backend still supports the legacy full-read Windows model for legacy `ReadOnly` and `WorkspaceWrite` behavior. It also diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 2686173208..7ea9f27197 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -63,7 +63,6 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::RealtimeVoice; use codex_protocol::protocol::SandboxPolicy; use serde::Deserialize; @@ -789,10 +788,6 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: config.permissions.sandbox_policy.get(), &SandboxPolicy::WorkspaceWrite { writable_roots: vec![memories_root], - read_only_access: ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![cwd.path().join("docs").abs(),], - }, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1150,10 +1145,6 @@ async fn permissions_profiles_allow_unknown_special_paths() -> std::io::Result<( assert_eq!( config.permissions.sandbox_policy.get(), &SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: Vec::new(), - }, network_access: false, } ); @@ -1219,10 +1210,6 @@ async fn permissions_profiles_allow_missing_filesystem_with_warning() -> std::io assert_eq!( config.permissions.sandbox_policy.get(), &SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: Vec::new(), - }, network_access: false, } ); @@ -1485,7 +1472,6 @@ trust_level = "trusted" resolution, SandboxPolicy::WorkspaceWrite { writable_roots: vec![writable_root.clone()], - read_only_access: ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1526,7 +1512,6 @@ exclude_slash_tmp = true resolution, SandboxPolicy::WorkspaceWrite { writable_roots: vec![writable_root], - read_only_access: ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 7f61952690..82d621a5f1 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -591,7 +591,6 @@ allowed_sandbox_modes = ["read-only"] .sandbox_policy .can_set(&SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/core/src/context/permissions_instructions_tests.rs b/codex-rs/core/src/context/permissions_instructions_tests.rs index 866c68b4a7..c8d4607bad 100644 --- a/codex-rs/core/src/context/permissions_instructions_tests.rs +++ b/codex-rs/core/src/context/permissions_instructions_tests.rs @@ -51,7 +51,6 @@ fn builds_permissions_with_network_access_override() { fn builds_permissions_from_policy() { let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 0c841693d3..ec5292d368 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -99,11 +99,13 @@ pub struct ExecParams { /// The unelevated restricted-token backend only consumes extra deny-write /// carveouts on top of the legacy `WorkspaceWrite` allow set. The elevated /// backend can also consume explicit read and write roots during setup/refresh. -/// Read-root overrides are layered on top of the baseline helper/platform roots -/// that the elevated setup path needs to launch the sandboxed command. +/// Read-root overrides are layered on top of the baseline helper roots that the +/// elevated setup path needs to launch the sandboxed command. Split policies +/// that opt into platform defaults carry that explicitly with the override. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct WindowsSandboxFilesystemOverrides { pub(crate) read_roots_override: Option>, + pub(crate) read_roots_include_platform_defaults: bool, pub(crate) write_roots_override: Option>, pub(crate) additional_deny_write_paths: Vec, } @@ -546,6 +548,8 @@ async fn exec_windows_sandbox( .unwrap_or_default(); let elevated_read_roots_override = windows_sandbox_filesystem_overrides .and_then(|overrides| overrides.read_roots_override.clone()); + let elevated_read_roots_include_platform_defaults = windows_sandbox_filesystem_overrides + .is_some_and(|overrides| overrides.read_roots_include_platform_defaults); let elevated_write_roots_override = windows_sandbox_filesystem_overrides .and_then(|overrides| overrides.write_roots_override.clone()); let elevated_deny_write_paths = windows_sandbox_filesystem_overrides @@ -571,6 +575,8 @@ async fn exec_windows_sandbox( use_private_desktop: windows_sandbox_private_desktop, proxy_enforced, read_roots_override: elevated_read_roots_override.as_deref(), + read_roots_include_platform_defaults: + elevated_read_roots_include_platform_defaults, write_roots_override: elevated_write_roots_override.as_deref(), deny_write_paths_override: &elevated_deny_write_paths, }, @@ -1064,6 +1070,7 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overrides( Ok(Some(WindowsSandboxFilesystemOverrides { read_roots_override: None, + read_roots_include_platform_defaults: false, write_roots_override: None, additional_deny_write_paths: additional_deny_write_paths .into_iter() @@ -1127,12 +1134,6 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides( .needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd); let normalize_path = |path: PathBuf| dunce::canonicalize(&path).unwrap_or(path); let legacy_writable_roots = sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); - let legacy_readable_root_set: BTreeSet = sandbox_policy - .get_readable_roots_with_cwd(sandbox_policy_cwd) - .into_iter() - .map(codex_utils_absolute_path::AbsolutePathBuf::into_path_buf) - .map(&normalize_path) - .collect(); let legacy_root_paths: BTreeSet = legacy_writable_roots .iter() .map(|root| normalize_path(root.root.to_path_buf())) @@ -1143,19 +1144,13 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides( .map(codex_utils_absolute_path::AbsolutePathBuf::into_path_buf) .map(&normalize_path) .collect(); - let split_readable_root_set: BTreeSet = split_readable_roots.iter().cloned().collect(); let split_root_paths: Vec = split_writable_roots .iter() .map(|root| normalize_path(root.root.to_path_buf())) .collect(); let split_root_path_set: BTreeSet = split_root_paths.iter().cloned().collect(); - let matches_legacy_read_access = file_system_sandbox_policy.has_full_disk_read_access() - == sandbox_policy.has_full_disk_read_access(); - let read_roots_override = if matches_legacy_read_access - && (file_system_sandbox_policy.has_full_disk_read_access() - || split_readable_root_set == legacy_readable_root_set) - { + let read_roots_override = if file_system_sandbox_policy.has_full_disk_read_access() { None } else { Some(split_readable_roots) @@ -1209,6 +1204,8 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides( } Ok(Some(WindowsSandboxFilesystemOverrides { + read_roots_include_platform_defaults: read_roots_override.is_some() + && file_system_sandbox_policy.include_platform_defaults(), read_roots_override, write_roots_override, additional_deny_write_paths, diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index 1cfa87ff3f..c09d4b48d3 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -470,7 +470,6 @@ fn windows_restricted_token_allows_legacy_restricted_policies() { fn windows_restricted_token_allows_legacy_workspace_write_policies() { let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -492,7 +491,7 @@ fn windows_restricted_token_allows_legacy_workspace_write_policies() { } #[test] -fn windows_elevated_allows_legacy_restricted_read_policies() { +fn windows_elevated_allows_split_restricted_read_policies() { let temp_dir = tempfile::TempDir::new().expect("tempdir"); let docs = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path( temp_dir.path().join("docs"), @@ -500,13 +499,14 @@ fn windows_elevated_allows_legacy_restricted_read_policies() { .expect("absolute docs"); std::fs::create_dir_all(docs.as_path()).expect("create docs"); let policy = SandboxPolicy::ReadOnly { - access: codex_protocol::protocol::ReadOnlyAccess::Restricted { - readable_roots: vec![docs], - include_platform_defaults: false, - }, network_access: false, }; - let file_system_policy = FileSystemSandboxPolicy::from(&policy); + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { path: docs }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); assert_eq!( unsupported_windows_restricted_token_sandbox_reason( @@ -528,7 +528,6 @@ fn windows_restricted_token_rejects_split_only_filesystem_policies() { std::fs::create_dir_all(&docs).expect("create docs"); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -572,7 +571,6 @@ fn windows_restricted_token_rejects_root_write_read_only_carveouts() { std::fs::create_dir_all(&docs).expect("create docs"); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -619,7 +617,6 @@ fn windows_restricted_token_supports_full_read_split_write_read_carveouts() { std::fs::create_dir_all(docs.as_path()).expect("create docs"); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -658,6 +655,7 @@ fn windows_restricted_token_supports_full_read_split_write_read_carveouts() { ), Ok(Some(WindowsSandboxFilesystemOverrides { read_roots_override: None, + read_roots_include_platform_defaults: false, write_roots_override: None, additional_deny_write_paths: expected_deny_write_paths, })) @@ -671,7 +669,6 @@ fn windows_elevated_supports_split_restricted_read_roots() { std::fs::create_dir_all(&docs).expect("create docs"); let expected_docs = dunce::canonicalize(&docs).expect("canonical docs"); let policy = SandboxPolicy::ReadOnly { - access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, }; let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ @@ -695,6 +692,7 @@ fn windows_elevated_supports_split_restricted_read_roots() { ), Ok(Some(WindowsSandboxFilesystemOverrides { read_roots_override: Some(vec![expected_docs]), + read_roots_include_platform_defaults: false, write_roots_override: None, additional_deny_write_paths: vec![], })) @@ -709,7 +707,6 @@ fn windows_elevated_supports_split_write_read_carveouts() { let expected_docs = dunce::canonicalize(&docs).expect("canonical docs"); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -747,6 +744,7 @@ fn windows_elevated_supports_split_write_read_carveouts() { ), Ok(Some(WindowsSandboxFilesystemOverrides { read_roots_override: None, + read_roots_include_platform_defaults: false, write_roots_override: None, additional_deny_write_paths: vec![ codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(expected_docs) @@ -763,7 +761,6 @@ fn windows_elevated_rejects_unreadable_split_carveouts() { std::fs::create_dir_all(&blocked).expect("create blocked"); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -811,7 +808,6 @@ fn windows_elevated_rejects_unreadable_globs() { let temp_dir = tempfile::TempDir::new().expect("tempdir"); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -861,7 +857,6 @@ fn windows_elevated_rejects_reopened_writable_descendants() { std::fs::create_dir_all(&nested).expect("create nested"); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/core/src/memories/phase2.rs b/codex-rs/core/src/memories/phase2.rs index ac1d0285d7..f780c0dc80 100644 --- a/codex-rs/core/src/memories/phase2.rs +++ b/codex-rs/core/src/memories/phase2.rs @@ -323,7 +323,6 @@ mod agent { // The consolidation agent only needs local memory-root write access and no network. let consolidation_sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots, - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/core/src/safety_tests.rs b/codex-rs/core/src/safety_tests.rs index a5892b2920..774673f887 100644 --- a/codex-rs/core/src/safety_tests.rs +++ b/codex-rs/core/src/safety_tests.rs @@ -28,7 +28,6 @@ fn test_writable_roots_constraint() { // only `cwd` is writable by default. let policy_workspace_only = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -50,7 +49,6 @@ fn test_writable_roots_constraint() { // outside write should be permitted. let policy_with_parent = SandboxPolicy::WorkspaceWrite { writable_roots: vec![parent], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -98,7 +96,6 @@ fn granular_with_all_flags_true_matches_on_request_for_out_of_root_patch() { let add_outside = ApplyPatchAction::new_add_for_test(&outside_path, "".to_string()); let policy_workspace_only = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -143,7 +140,6 @@ fn granular_sandbox_approval_false_rejects_out_of_root_patch() { let add_outside = ApplyPatchAction::new_add_for_test(&outside_path, "".to_string()); let policy_workspace_only = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -294,7 +290,6 @@ fn missing_project_dot_codex_config_requires_approval() { let action = ApplyPatchAction::new_add_for_test(&config_path, "".to_string()); let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 3208f97dcb..f29f6c888f 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -43,7 +43,6 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::protocol::NonSteerableTurnKind; -use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; @@ -2836,10 +2835,6 @@ async fn session_configuration_apply_preserves_split_file_system_policy_on_cwd_o session_configuration.sandbox_policy = codex_config::Constrained::allow_any(SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), - read_only_access: ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![docs_dir.clone()], - }, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -3010,18 +3005,10 @@ async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_ let workspace = tempfile::tempdir().expect("create temp dir"); let project_root = workspace.path().join("project"); let original_cwd = project_root.join("subdir"); - let docs_dir = original_cwd.join("docs"); - std::fs::create_dir_all(&docs_dir).expect("create docs dir"); - let docs_dir = docs_dir.abs(); - session_configuration.cwd = original_cwd.abs(); session_configuration.sandbox_policy = codex_config::Constrained::allow_any(SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), - read_only_access: ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![docs_dir], - }, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, 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 f3d9bdd7de..230e656225 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch_tests.rs @@ -239,7 +239,6 @@ fn write_permissions_for_paths_skip_dirs_already_writable_under_workspace_root() .expect("nested file path should be absolute"); let sandbox_policy = FileSystemSandboxPolicy::from(&SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: false, @@ -262,7 +261,6 @@ fn write_permissions_for_paths_keep_dirs_outside_workspace_root() { let cwd_abs = cwd.abs(); let sandbox_policy = FileSystemSandboxPolicy::from(&SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 86753a04d7..927d1b1ce9 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -28,7 +28,6 @@ use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::GuardianCommandSource; -use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_sandboxing::SandboxType; use codex_shell_escalation::EscalationExecution; @@ -269,7 +268,6 @@ fn shell_request_escalation_execution_is_explicit() { }; let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![AbsolutePathBuf::from_absolute_path("/tmp/original/output").unwrap()], - read_only_access: ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/core/tests/common/zsh_fork.rs b/codex-rs/core/tests/common/zsh_fork.rs index e61d3ea950..bc87c9ea93 100644 --- a/codex-rs/core/tests/common/zsh_fork.rs +++ b/codex-rs/core/tests/common/zsh_fork.rs @@ -43,7 +43,6 @@ impl ZshForkRuntime { pub fn restrictive_workspace_write_policy() -> SandboxPolicy { SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 588dd98d50..4bb3be6631 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -643,7 +643,6 @@ async fn apply_patch_cli_rejects_path_traversal_outside_workspace( let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -699,7 +698,6 @@ async fn apply_patch_cli_rejects_move_path_traversal_outside_workspace( let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index f915ec45a3..c16cc86fdc 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -768,7 +768,6 @@ fn scenarios() -> Vec { let workspace_write = |network_access| SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -1799,7 +1798,6 @@ async fn approving_apply_patch_for_session_skips_future_prompts_for_same_file() let approval_policy = AskForApproval::OnRequest; let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -2529,7 +2527,6 @@ allow_local_binding = true let approval_policy = AskForApproval::OnFailure; let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -2831,7 +2828,6 @@ allow_local_binding = true let approval_policy = AskForApproval::OnFailure; let turn_sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/core/tests/suite/hooks.rs b/codex-rs/core/tests/suite/hooks.rs index 3a0669c008..c683d353a3 100644 --- a/codex-rs/core/tests/suite/hooks.rs +++ b/codex-rs/core/tests/suite/hooks.rs @@ -1417,7 +1417,6 @@ async fn permission_request_hook_allows_apply_patch_with_write_alias() -> Result AskForApproval::OnRequest, SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1566,7 +1565,6 @@ allow_local_binding = true let approval_policy = AskForApproval::OnFailure; let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 1bf2ee121c..fea2283758 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -542,7 +542,6 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { let writable_root = AbsolutePathBuf::try_from(writable.path())?; let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![writable_root], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index ca0b3e281a..2e168bd729 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -433,7 +433,6 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an let writable = TempDir::new().unwrap(); let new_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![writable.path().try_into().unwrap()], - read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -712,7 +711,6 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res let writable = TempDir::new().unwrap(); let new_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![writable.abs()], - read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 1e781a669b..36cc2e6812 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -4,8 +4,12 @@ use codex_exec_server::CopyOptions; use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::FileSystemSandboxContext; use codex_exec_server::RemoveOptions; -use codex_protocol::protocol::ReadOnlyAccess; -use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::models::PermissionProfile; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; +use codex_protocol::permissions::FileSystemSandboxPolicy; +use codex_protocol::permissions::NetworkSandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::PathBufExt; use core_test_support::get_remote_test_env; @@ -60,37 +64,28 @@ fn absolute_path(path: PathBuf) -> AbsolutePathBuf { fn read_only_sandbox(readable_root: PathBuf) -> FileSystemSandboxContext { let readable_root = absolute_path(readable_root); - // The policy is evaluated in the remote container, so use a container path - // for cwd instead of capturing the local test runner cwd. - FileSystemSandboxContext::from_legacy_sandbox_policy( - SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: vec![readable_root.clone()], + FileSystemSandboxContext::from_permission_profile(PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: readable_root, }, - network_access: false, - }, - readable_root, - ) + access: FileSystemAccessMode::Read, + }]), + NetworkSandboxPolicy::Restricted, + )) } fn workspace_write_sandbox(writable_root: PathBuf) -> FileSystemSandboxContext { let writable_root = absolute_path(writable_root); - // The policy is evaluated in the remote container, so use a container path - // for cwd instead of capturing the local test runner cwd. - FileSystemSandboxContext::from_legacy_sandbox_policy( - SandboxPolicy::WorkspaceWrite { - writable_roots: vec![writable_root.clone()], - read_only_access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: vec![], + FileSystemSandboxContext::from_permission_profile(PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: writable_root, }, - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }, - writable_root, - ) + access: FileSystemAccessMode::Write, + }]), + NetworkSandboxPolicy::Restricted, + )) } fn assert_normalized_path_rejected(error: &std::io::Error) { diff --git a/codex-rs/core/tests/suite/request_permissions.rs b/codex-rs/core/tests/suite/request_permissions.rs index 319e3ef8ec..8719bba9ff 100644 --- a/codex-rs/core/tests/suite/request_permissions.rs +++ b/codex-rs/core/tests/suite/request_permissions.rs @@ -286,7 +286,6 @@ async fn expect_request_permissions_event( fn workspace_write_excluding_tmp() -> SandboxPolicy { SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/core/tests/suite/request_permissions_tool.rs b/codex-rs/core/tests/suite/request_permissions_tool.rs index 4df6602cdc..8bd83f58b5 100644 --- a/codex-rs/core/tests/suite/request_permissions_tool.rs +++ b/codex-rs/core/tests/suite/request_permissions_tool.rs @@ -73,7 +73,6 @@ fn build_add_file_patch(patch_path: &Path, content: &str) -> String { fn workspace_write_excluding_tmp() -> SandboxPolicy { SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs index 5df7e516a8..cd9a73696d 100644 --- a/codex-rs/core/tests/suite/responses_api_proxy_headers.rs +++ b/codex-rs/core/tests/suite/responses_api_proxy_headers.rs @@ -138,7 +138,6 @@ async fn submit_turn_with_timeout(test: &TestCodex, prompt: &str) -> Result<()> approvals_reviewer: None, sandbox_policy: SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/exec-server/src/fs_sandbox.rs b/codex-rs/exec-server/src/fs_sandbox.rs index b9f7456f3c..a1c77fb88a 100644 --- a/codex-rs/exec-server/src/fs_sandbox.rs +++ b/codex-rs/exec-server/src/fs_sandbox.rs @@ -7,7 +7,6 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxExecRequest; @@ -194,14 +193,6 @@ fn compatibility_workspace_write_policy( file_system_policy: &FileSystemSandboxPolicy, cwd: &std::path::Path, ) -> SandboxPolicy { - let read_only_access = if file_system_policy.has_full_disk_read_access() { - ReadOnlyAccess::FullAccess - } else { - ReadOnlyAccess::Restricted { - include_platform_defaults: file_system_policy.include_platform_defaults(), - readable_roots: file_system_policy.get_readable_roots_with_cwd(cwd), - } - }; let cwd_abs = AbsolutePathBuf::from_absolute_path(cwd).ok(); let writable_roots = file_system_policy .get_writable_roots_with_cwd(cwd) @@ -212,7 +203,6 @@ fn compatibility_workspace_write_policy( SandboxPolicy::WorkspaceWrite { writable_roots, - read_only_access, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 4060d07da2..4dab204493 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -484,7 +484,6 @@ fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String { network_access, exclude_tmpdir_env_var, exclude_slash_tmp, - read_only_access: _, } => { let mut summary = "workspace-write".to_string(); let mut writable_entries = vec!["workdir".to_string()]; diff --git a/codex-rs/exec/src/lib_tests.rs b/codex-rs/exec/src/lib_tests.rs index 0ec1fbc59e..bcb17fb87d 100644 --- a/codex-rs/exec/src/lib_tests.rs +++ b/codex-rs/exec/src/lib_tests.rs @@ -422,7 +422,6 @@ fn session_configured_from_thread_response_uses_review_policy_from_response() { approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview, sandbox: codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: codex_app_server_protocol::ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/exec/tests/suite/sandbox.rs b/codex-rs/exec/tests/suite/sandbox.rs index cd5459d1fc..aa41464ec3 100644 --- a/codex-rs/exec/tests/suite/sandbox.rs +++ b/codex-rs/exec/tests/suite/sandbox.rs @@ -182,7 +182,6 @@ async fn python_multiprocessing_lock_works_under_sandbox() { let policy = SandboxPolicy::WorkspaceWrite { writable_roots, - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -297,7 +296,6 @@ async fn sandbox_distinguishes_command_and_policy_cwds() { // is under a writable root. let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -379,7 +377,6 @@ async fn sandbox_blocks_first_time_dot_codex_creation() { let config_toml = dot_codex.join("config.toml"); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/linux-sandbox/src/bwrap.rs b/codex-rs/linux-sandbox/src/bwrap.rs index 64d1342bef..08ecda6c51 100644 --- a/codex-rs/linux-sandbox/src/bwrap.rs +++ b/codex-rs/linux-sandbox/src/bwrap.rs @@ -32,7 +32,7 @@ use globset::GlobSet; use globset::GlobSetBuilder; /// Linux "platform defaults" that keep common system binaries and dynamic -/// libraries readable when `ReadOnlyAccess::Restricted` requests them. +/// libraries readable when a split filesystem policy requests `:minimal`. /// /// These are intentionally system-level paths only (plus Nix store roots) so /// `include_platform_defaults` does not silently widen access to user data. @@ -1002,7 +1002,6 @@ mod tests { use codex_protocol::protocol::FileSystemSandboxEntry; use codex_protocol::protocol::FileSystemSandboxPolicy; use codex_protocol::protocol::FileSystemSpecialPath; - use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -1371,7 +1370,6 @@ mod tests { AbsolutePathBuf::try_from(existing_root.as_path()).expect("absolute existing root"), AbsolutePathBuf::try_from(missing_root.as_path()).expect("absolute missing root"), ], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1402,7 +1400,6 @@ mod tests { fn mounts_dev_before_writable_dev_binds() { let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![AbsolutePathBuf::try_from(Path::new("/dev")).expect("/dev path")], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1449,23 +1446,17 @@ mod tests { let readable_root = temp_dir.path().join("readable"); std::fs::create_dir(&readable_root).expect("create readable root"); - let policy = SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: vec![ - AbsolutePathBuf::try_from(readable_root.as_path()) - .expect("absolute readable root"), - ], + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: AbsolutePathBuf::try_from(readable_root.as_path()) + .expect("absolute readable root"), }, - network_access: false, - }; + access: FileSystemAccessMode::Read, + }]); - let args = create_filesystem_args( - &FileSystemSandboxPolicy::from(&policy), - temp_dir.path(), - NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH, - ) - .expect("filesystem args"); + let args = + create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) + .expect("filesystem args"); assert_eq!(args.args[0..4], ["--tmpfs", "/", "--dev", "/dev"]); @@ -1483,23 +1474,16 @@ mod tests { #[test] fn restricted_read_only_with_platform_defaults_includes_usr_when_present() { let temp_dir = TempDir::new().expect("temp dir"); - let policy = SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: Vec::new(), + let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Minimal, }, - network_access: false, - }; + access: FileSystemAccessMode::Read, + }]); - // `ReadOnlyAccess::Restricted` always includes `cwd` as a readable - // root. Using `"/"` here would intentionally collapse to broad read - // access, so use a non-root cwd to exercise the restricted path. - let args = create_filesystem_args( - &FileSystemSandboxPolicy::from(&policy), - temp_dir.path(), - NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH, - ) - .expect("filesystem args"); + let args = + create_filesystem_args(&policy, temp_dir.path(), NO_UNREADABLE_GLOB_SCAN_MAX_DEPTH) + .expect("filesystem args"); assert!( args.args diff --git a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs index 0ed10717f2..0eef358424 100644 --- a/codex-rs/linux-sandbox/src/linux_run_main_tests.rs +++ b/codex-rs/linux-sandbox/src/linux_run_main_tests.rs @@ -5,8 +5,6 @@ use codex_protocol::protocol::FileSystemSandboxPolicy; #[cfg(test)] use codex_protocol::protocol::NetworkSandboxPolicy; #[cfg(test)] -use codex_protocol::protocol::ReadOnlyAccess; -#[cfg(test)] use codex_protocol::protocol::SandboxPolicy; #[cfg(test)] use codex_utils_absolute_path::AbsolutePathBuf; @@ -456,7 +454,6 @@ fn resolve_sandbox_policies_accepts_semantically_equivalent_workspace_write_inpu let workspace = AbsolutePathBuf::from_absolute_path(&workspace).expect("absolute workspace"); let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![workspace], - read_only_access: ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index 3795719f08..17ee7dd8aa 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -16,7 +16,6 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -87,7 +86,6 @@ async fn run_cmd_result_with_writable_roots( .iter() .map(|p| AbsolutePathBuf::try_from(p.as_path()).unwrap()) .collect(), - read_only_access: Default::default(), network_access, // Exclude tmp-related folders from writable roots because we need a // folder that is writable by tests but that we intentionally disallow @@ -561,7 +559,6 @@ async fn sandbox_blocks_explicit_split_policy_carveouts_under_bwrap() { let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![AbsolutePathBuf::try_from(tmpdir.path()).expect("absolute tempdir")], - read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -634,7 +631,6 @@ async fn sandbox_reenables_writable_subpaths_under_unreadable_parents() { let sandbox_policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![AbsolutePathBuf::try_from(tmpdir.path()).expect("absolute tempdir")], - read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -709,7 +705,6 @@ async fn sandbox_blocks_root_read_carveouts_under_bwrap() { std::fs::write(&blocked_target, "secret").expect("seed blocked file"); let sandbox_policy = SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, network_access: true, }; let file_system_sandbox_policy = FileSystemSandboxPolicy::restricted(vec![ diff --git a/codex-rs/protocol/src/permissions.rs b/codex-rs/protocol/src/permissions.rs index c1580a90fc..450fb39974 100644 --- a/codex-rs/protocol/src/permissions.rs +++ b/codex-rs/protocol/src/permissions.rs @@ -16,7 +16,6 @@ use tracing::error; use ts_rs::TS; use crate::protocol::NetworkAccess; -use crate::protocol::ReadOnlyAccess; use crate::protocol::SandboxPolicy; use crate::protocol::WritableRoot; @@ -834,12 +833,9 @@ impl FileSystemSandboxPolicy { } FileSystemSandboxKind::Restricted => { let cwd_absolute = AbsolutePathBuf::from_absolute_path(cwd).ok(); - let mut include_platform_defaults = false; - let has_full_disk_read_access = self.has_full_disk_read_access(); let has_full_disk_write_access = self.has_full_disk_write_access(); let mut workspace_root_writable = false; let mut writable_roots = Vec::new(); - let mut readable_roots = Vec::new(); let mut tmpdir_writable = false; let mut slash_tmp_writable = false; let mut unbridgeable_root_write = false; @@ -854,39 +850,20 @@ impl FileSystemSandboxPolicy { } else { writable_roots.push(path.clone()); } - } else if entry.access.can_read() { - readable_roots.push(path.clone()); } } FileSystemPath::Special { value } => match value { FileSystemSpecialPath::Root => match entry.access { FileSystemAccessMode::None => {} - FileSystemAccessMode::Read => { - if !has_full_disk_read_access - && let Some(cwd) = cwd_absolute.as_ref() - { - readable_roots.push(absolute_root_path_for_cwd(cwd)); - } - } + FileSystemAccessMode::Read => {} FileSystemAccessMode::Write => { unbridgeable_root_write = true; } }, - FileSystemSpecialPath::Minimal => { - if entry.access.can_read() { - include_platform_defaults = true; - } - } + FileSystemSpecialPath::Minimal => {} FileSystemSpecialPath::CurrentWorkingDirectory => { if entry.access.can_write() { workspace_root_writable = true; - } else if entry.access.can_read() - && let Some(path) = resolve_file_system_special_path( - value, - cwd_absolute.as_ref(), - ) - { - readable_roots.push(path); } } FileSystemSpecialPath::ProjectRoots { subpath } => { @@ -894,36 +871,19 @@ impl FileSystemSandboxPolicy { workspace_root_writable = true; } else if let Some(path) = resolve_file_system_special_path(value, cwd_absolute.as_ref()) + && entry.access.can_write() { - if entry.access.can_write() { - writable_roots.push(path); - } else if entry.access.can_read() { - readable_roots.push(path); - } + writable_roots.push(path); } } FileSystemSpecialPath::Tmpdir => { if entry.access.can_write() { tmpdir_writable = true; - } else if entry.access.can_read() - && let Some(path) = resolve_file_system_special_path( - value, - cwd_absolute.as_ref(), - ) - { - readable_roots.push(path); } } FileSystemSpecialPath::SlashTmp => { if entry.access.can_write() { slash_tmp_writable = true; - } else if entry.access.can_read() - && let Some(path) = resolve_file_system_special_path( - value, - cwd_absolute.as_ref(), - ) - { - readable_roots.push(path); } } FileSystemSpecialPath::Unknown { .. } => {} @@ -941,25 +901,12 @@ impl FileSystemSandboxPolicy { }); } - let read_only_access = if has_full_disk_read_access { - ReadOnlyAccess::FullAccess - } else { - ReadOnlyAccess::Restricted { - include_platform_defaults, - readable_roots: dedup_absolute_paths( - readable_roots, - /*normalize_effective_paths*/ false, - ), - } - }; - if workspace_root_writable { SandboxPolicy::WorkspaceWrite { writable_roots: dedup_absolute_paths( writable_roots, /*normalize_effective_paths*/ false, ), - read_only_access, network_access: network_policy.is_enabled(), exclude_tmpdir_env_var: !tmpdir_writable, exclude_slash_tmp: !slash_tmp_writable, @@ -975,7 +922,6 @@ impl FileSystemSandboxPolicy { )); } else { SandboxPolicy::ReadOnly { - access: read_only_access, network_access: network_policy.is_enabled(), } } @@ -1026,78 +972,26 @@ impl From<&SandboxPolicy> for FileSystemSandboxPolicy { match value { SandboxPolicy::DangerFullAccess => FileSystemSandboxPolicy::unrestricted(), SandboxPolicy::ExternalSandbox { .. } => FileSystemSandboxPolicy::external_sandbox(), - SandboxPolicy::ReadOnly { access, .. } => { - let mut entries = Vec::new(); - match access { - ReadOnlyAccess::FullAccess => entries.push(FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }), - ReadOnlyAccess::Restricted { - include_platform_defaults, - readable_roots, - } => { - entries.push(FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::CurrentWorkingDirectory, - }, - access: FileSystemAccessMode::Read, - }); - if *include_platform_defaults { - entries.push(FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Minimal, - }, - access: FileSystemAccessMode::Read, - }); - } - entries.extend(readable_roots.iter().cloned().map(|path| { - FileSystemSandboxEntry { - path: FileSystemPath::Path { path }, - access: FileSystemAccessMode::Read, - } - })); - } - } - FileSystemSandboxPolicy::restricted(entries) + SandboxPolicy::ReadOnly { .. } => { + FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }]) } SandboxPolicy::WorkspaceWrite { writable_roots, - read_only_access, exclude_tmpdir_env_var, exclude_slash_tmp, .. } => { - let mut entries = Vec::new(); - match read_only_access { - ReadOnlyAccess::FullAccess => entries.push(FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Root, - }, - access: FileSystemAccessMode::Read, - }), - ReadOnlyAccess::Restricted { - include_platform_defaults, - readable_roots, - } => { - if *include_platform_defaults { - entries.push(FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::Minimal, - }, - access: FileSystemAccessMode::Read, - }); - } - entries.extend(readable_roots.iter().cloned().map(|path| { - FileSystemSandboxEntry { - path: FileSystemPath::Path { path }, - access: FileSystemAccessMode::Read, - } - })); - } - } + let mut entries = vec![FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }]; entries.push(FileSystemSandboxEntry { path: FileSystemPath::Special { @@ -1592,15 +1486,23 @@ mod tests { #[test] fn unknown_special_paths_are_ignored_by_legacy_bridge() -> std::io::Result<()> { - let policy = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { - path: FileSystemPath::Special { - value: FileSystemSpecialPath::unknown( - ":future_special_path", - /*subpath*/ None, - ), + let policy = FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, }, - access: FileSystemAccessMode::Write, - }]); + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::unknown( + ":future_special_path", + /*subpath*/ None, + ), + }, + access: FileSystemAccessMode::Write, + }, + ]); let sandbox_policy = policy.to_legacy_sandbox_policy( NetworkSandboxPolicy::Restricted, @@ -1610,10 +1512,6 @@ mod tests { assert_eq!( sandbox_policy, SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: Vec::new(), - }, network_access: false, } ); @@ -1651,10 +1549,6 @@ mod tests { fn legacy_workspace_write_projection_preserves_symbolic_cwd() { let policy = SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), - read_only_access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: Vec::new(), - }, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1663,6 +1557,12 @@ mod tests { assert_eq!( FileSystemSandboxPolicy::from_legacy_sandbox_policy(&policy), FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::CurrentWorkingDirectory, @@ -1741,10 +1641,6 @@ mod tests { let dot_codex_config = cwd.path().join(".codex").join("config.toml"); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: vec![], - }, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1768,10 +1664,6 @@ mod tests { .expect("absolute dot codex"); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: vec![], - }, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1783,6 +1675,12 @@ mod tests { assert_eq!( file_system_policy, FileSystemSandboxPolicy::restricted(vec![ + FileSystemSandboxEntry { + path: FileSystemPath::Special { + value: FileSystemSpecialPath::Root, + }, + access: FileSystemAccessMode::Read, + }, FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::CurrentWorkingDirectory, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index a79aa1f010..63e8ab0e50 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1025,76 +1025,6 @@ impl NetworkAccess { matches!(self, NetworkAccess::Enabled) } } -fn default_include_platform_defaults() -> bool { - true -} - -/// Determines how read-only file access is granted inside a restricted -/// sandbox. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, Default, JsonSchema, TS)] -#[strum(serialize_all = "kebab-case")] -#[serde(tag = "type", rename_all = "kebab-case")] -#[ts(tag = "type")] -pub enum ReadOnlyAccess { - /// Restrict reads to an explicit set of roots. - /// - /// When `include_platform_defaults` is `true`, platform defaults required - /// for basic execution are included in addition to `readable_roots`. - Restricted { - /// Include built-in platform read roots required for basic process - /// execution. - #[serde(default = "default_include_platform_defaults")] - include_platform_defaults: bool, - /// Additional absolute roots that should be readable. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - readable_roots: Vec, - }, - - /// Allow unrestricted file reads. - #[default] - FullAccess, -} - -impl ReadOnlyAccess { - pub fn has_full_disk_read_access(&self) -> bool { - matches!(self, ReadOnlyAccess::FullAccess) - } - - /// Returns true if platform defaults should be included for restricted read access. - pub fn include_platform_defaults(&self) -> bool { - matches!( - self, - ReadOnlyAccess::Restricted { - include_platform_defaults: true, - .. - } - ) - } - - /// Returns the readable roots for restricted read access. - /// - /// For [`ReadOnlyAccess::FullAccess`], returns an empty list because - /// callers should grant blanket read access instead. - pub fn get_readable_roots_with_cwd(&self, cwd: &Path) -> Vec { - let mut roots: Vec = match self { - ReadOnlyAccess::FullAccess => return Vec::new(), - ReadOnlyAccess::Restricted { readable_roots, .. } => { - let mut roots = readable_roots.clone(); - match AbsolutePathBuf::from_absolute_path(cwd) { - Ok(cwd_root) => roots.push(cwd_root), - Err(err) => { - error!("Ignoring invalid cwd {cwd:?} for sandbox readable root: {err}"); - } - } - roots - } - }; - - let mut seen = HashSet::new(); - roots.retain(|root| seen.insert(root.to_path_buf())); - roots - } -} /// Determines execution restrictions for model shell commands. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Display, JsonSchema, TS)] @@ -1108,13 +1038,6 @@ pub enum SandboxPolicy { /// Read-only access configuration. #[serde(rename = "read-only")] ReadOnly { - /// Read access granted while running under this policy. - #[serde( - default, - skip_serializing_if = "ReadOnlyAccess::has_full_disk_read_access" - )] - access: ReadOnlyAccess, - /// When set to `true`, outbound network access is allowed. `false` by /// default. #[serde(default, skip_serializing_if = "std::ops::Not::not")] @@ -1139,13 +1062,6 @@ pub enum SandboxPolicy { #[serde(default, skip_serializing_if = "Vec::is_empty")] writable_roots: Vec, - /// Read access granted while running under this policy. - #[serde( - default, - skip_serializing_if = "ReadOnlyAccess::has_full_disk_read_access" - )] - read_only_access: ReadOnlyAccess, - /// When set to `true`, outbound network access is allowed. `false` by /// default. #[serde(default)] @@ -1223,7 +1139,6 @@ impl SandboxPolicy { /// Returns a policy with read-only disk access and no network. pub fn new_read_only_policy() -> Self { SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, network_access: false, } } @@ -1234,7 +1149,6 @@ impl SandboxPolicy { pub fn new_workspace_write_policy() -> Self { SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -1242,14 +1156,7 @@ impl SandboxPolicy { } pub fn has_full_disk_read_access(&self) -> bool { - match self { - SandboxPolicy::DangerFullAccess => true, - SandboxPolicy::ExternalSandbox { .. } => true, - SandboxPolicy::ReadOnly { access, .. } => access.has_full_disk_read_access(), - SandboxPolicy::WorkspaceWrite { - read_only_access, .. - } => read_only_access.has_full_disk_read_access(), - } + true } pub fn has_full_disk_write_access(&self) -> bool { @@ -1270,46 +1177,6 @@ impl SandboxPolicy { } } - /// Returns true if platform defaults should be included for restricted read access. - pub fn include_platform_defaults(&self) -> bool { - if self.has_full_disk_read_access() { - return false; - } - match self { - SandboxPolicy::ReadOnly { access, .. } => access.include_platform_defaults(), - SandboxPolicy::WorkspaceWrite { - read_only_access, .. - } => read_only_access.include_platform_defaults(), - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => false, - } - } - - /// Returns the list of readable roots (tailored to the current working - /// directory) when read access is restricted. - /// - /// For policies with full read access, this returns an empty list because - /// callers should grant blanket reads. - pub fn get_readable_roots_with_cwd(&self, cwd: &Path) -> Vec { - let mut roots = match self { - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => Vec::new(), - SandboxPolicy::ReadOnly { access, .. } => access.get_readable_roots_with_cwd(cwd), - SandboxPolicy::WorkspaceWrite { - read_only_access, .. - } => { - let mut roots = read_only_access.get_readable_roots_with_cwd(cwd); - roots.extend( - self.get_writable_roots_with_cwd(cwd) - .into_iter() - .map(|root| root.root), - ); - roots - } - }; - let mut seen = HashSet::new(); - roots.retain(|root| seen.insert(root.to_path_buf())); - roots - } - /// Returns the list of writable roots (tailored to the current working /// directory) together with subpaths that should remain read‑only under /// each writable root. @@ -1320,7 +1187,6 @@ impl SandboxPolicy { SandboxPolicy::ReadOnly { .. } => Vec::new(), SandboxPolicy::WorkspaceWrite { writable_roots, - read_only_access: _, exclude_tmpdir_env_var, exclude_slash_tmp, network_access: _, @@ -4077,19 +3943,8 @@ mod tests { sorted_roots } - fn sandbox_policy_allows_read(policy: &SandboxPolicy, path: &Path, cwd: &Path) -> bool { - if policy.has_full_disk_read_access() { - return true; - } - - policy - .get_readable_roots_with_cwd(cwd) - .iter() - .any(|root| path.starts_with(root.as_path())) - || policy - .get_writable_roots_with_cwd(cwd) - .iter() - .any(|root| path.starts_with(root.root.as_path())) + fn sandbox_policy_allows_read(policy: &SandboxPolicy, _path: &Path, _cwd: &Path) -> bool { + policy.has_full_disk_read_access() } fn sandbox_policy_allows_write(policy: &SandboxPolicy, path: &Path, cwd: &Path) -> bool { @@ -4217,12 +4072,6 @@ mod tests { fn sandbox_policy_probe_paths(policy: &SandboxPolicy, cwd: &Path) -> Vec { let mut paths = vec![cwd.to_path_buf()]; - paths.extend( - policy - .get_readable_roots_with_cwd(cwd) - .into_iter() - .map(|path| path.to_path_buf()), - ); for root in policy.get_writable_roots_with_cwd(cwd) { paths.push(root.root.to_path_buf()); paths.extend( @@ -4253,10 +4102,6 @@ mod tests { actual.has_full_network_access(), expected.has_full_network_access() ); - assert_eq!( - actual.include_platform_defaults(), - expected.include_platform_defaults() - ); let mut probe_paths = sandbox_policy_probe_paths(expected, cwd); probe_paths.extend(sandbox_policy_probe_paths(actual, cwd)); probe_paths.sort(); @@ -4299,7 +4144,6 @@ mod tests { assert!(!restricted.has_full_network_access()); let enabled = SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, network_access: true, }; assert!(enabled.has_full_network_access()); @@ -4398,38 +4242,6 @@ mod tests { ); } - #[test] - fn workspace_write_restricted_read_access_includes_effective_writable_roots() { - let cwd = if cfg!(windows) { - Path::new(r"C:\workspace") - } else { - Path::new("/tmp/workspace") - }; - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - read_only_access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: vec![], - }, - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: false, - }; - - let readable_roots = policy.get_readable_roots_with_cwd(cwd); - let writable_roots = policy.get_writable_roots_with_cwd(cwd); - - for writable_root in writable_roots { - assert!( - readable_roots - .iter() - .any(|root| root.as_path() == writable_root.root.as_path()), - "expected writable root {} to also be readable", - writable_root.root.as_path().display() - ); - } - } - #[test] fn restricted_file_system_policy_reports_full_access_from_root_entries() { let read_only = FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry { @@ -4623,34 +4435,6 @@ mod tests { ); } - #[test] - fn legacy_workspace_write_nested_readable_root_stays_writable() { - let cwd = TempDir::new().expect("tempdir"); - let docs = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()); - let canonical_cwd = codex_utils_absolute_path::canonicalize_preserving_symlinks(cwd.path()) - .expect("canonicalize cwd"); - let expected_dot_codex = AbsolutePathBuf::from_absolute_path(canonical_cwd.join(".codex")) - .expect("canonical .codex"); - let policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - read_only_access: ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![docs], - }, - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }; - - assert_eq!( - sorted_writable_roots( - FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, cwd.path()) - .get_writable_roots_with_cwd(cwd.path()) - ), - vec![(canonical_cwd, vec![expected_dot_codex.to_path_buf()])] - ); - } - #[test] fn file_system_policy_rejects_legacy_bridge_for_non_workspace_writes() { let cwd = if cfg!(windows) { @@ -4684,9 +4468,7 @@ mod tests { #[test] fn legacy_sandbox_policy_semantics_survive_split_bridge() { let cwd = TempDir::new().expect("tempdir"); - let readable_root = AbsolutePathBuf::resolve_path_against_base("readable", cwd.path()); let writable_root = AbsolutePathBuf::resolve_path_against_base("writable", cwd.path()); - let nested_readable_root = AbsolutePathBuf::resolve_path_against_base("docs", cwd.path()); let policies = [ SandboxPolicy::DangerFullAccess, SandboxPolicy::ExternalSandbox { @@ -4696,43 +4478,20 @@ mod tests { network_access: NetworkAccess::Enabled, }, SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, network_access: false, }, - SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![readable_root.clone()], - }, - network_access: true, - }, SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, }, SandboxPolicy::WorkspaceWrite { writable_roots: vec![writable_root], - read_only_access: ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![readable_root], - }, network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: true, }, - SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - read_only_access: ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![nested_readable_root], - }, - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }, ]; for expected in policies { diff --git a/codex-rs/sandboxing/src/manager_tests.rs b/codex-rs/sandboxing/src/manager_tests.rs index a7dca2bf57..d9c3e194f3 100644 --- a/codex-rs/sandboxing/src/manager_tests.rs +++ b/codex-rs/sandboxing/src/manager_tests.rs @@ -15,7 +15,6 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::NetworkAccess; -use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use dunce::canonicalize; @@ -191,7 +190,6 @@ fn transform_additional_permissions_preserves_denied_entries() { }), }, policy: &SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, network_access: false, }, file_system_policy: &FileSystemSandboxPolicy::restricted(vec![ diff --git a/codex-rs/sandboxing/src/policy_transforms.rs b/codex-rs/sandboxing/src/policy_transforms.rs index 065d96e8bb..20a026d005 100644 --- a/codex-rs/sandboxing/src/policy_transforms.rs +++ b/codex-rs/sandboxing/src/policy_transforms.rs @@ -10,7 +10,6 @@ use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::permissions::ReadDenyMatcher; use codex_protocol::protocol::NetworkAccess; -use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::canonicalize_preserving_symlinks; @@ -535,26 +534,6 @@ pub fn effective_file_system_sandbox_policy( } } -fn merge_read_only_access_with_additional_reads( - read_only_access: &ReadOnlyAccess, - extra_reads: Vec, -) -> ReadOnlyAccess { - match read_only_access { - ReadOnlyAccess::FullAccess => ReadOnlyAccess::FullAccess, - ReadOnlyAccess::Restricted { - include_platform_defaults, - readable_roots, - } => { - let mut merged = readable_roots.clone(); - merged.extend(extra_reads); - ReadOnlyAccess::Restricted { - include_platform_defaults: *include_platform_defaults, - readable_roots: dedup_absolute_paths(merged), - } - } - } -} - fn merge_network_access( base_network_access: bool, additional_permissions: &AdditionalPermissionProfile, @@ -590,7 +569,7 @@ fn sandbox_policy_with_additional_permissions( return sandbox_policy.clone(); } - let (extra_reads, extra_writes) = additional_permission_roots(additional_permissions); + let (_extra_reads, extra_writes) = additional_permission_roots(additional_permissions); match sandbox_policy { SandboxPolicy::DangerFullAccess => SandboxPolicy::DangerFullAccess, @@ -606,7 +585,6 @@ fn sandbox_policy_with_additional_permissions( }, SandboxPolicy::WorkspaceWrite { writable_roots, - read_only_access, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, @@ -615,22 +593,14 @@ fn sandbox_policy_with_additional_permissions( merged_writes.extend(extra_writes); SandboxPolicy::WorkspaceWrite { writable_roots: dedup_absolute_paths(merged_writes), - read_only_access: merge_read_only_access_with_additional_reads( - read_only_access, - extra_reads, - ), network_access: merge_network_access(*network_access, additional_permissions), exclude_tmpdir_env_var: *exclude_tmpdir_env_var, exclude_slash_tmp: *exclude_slash_tmp, } } - SandboxPolicy::ReadOnly { - access, - network_access, - } => { + SandboxPolicy::ReadOnly { network_access } => { if extra_writes.is_empty() { SandboxPolicy::ReadOnly { - access: merge_read_only_access_with_additional_reads(access, extra_reads), network_access: merge_network_access(*network_access, additional_permissions), } } else { @@ -639,10 +609,6 @@ fn sandbox_policy_with_additional_permissions( // UnderDevelopment, it's a useful approximation of the desired behavior. SandboxPolicy::WorkspaceWrite { writable_roots: dedup_absolute_paths(extra_writes), - read_only_access: merge_read_only_access_with_additional_reads( - access, - extra_reads, - ), network_access: merge_network_access(*network_access, additional_permissions), exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/sandboxing/src/policy_transforms_tests.rs b/codex-rs/sandboxing/src/policy_transforms_tests.rs index 876cbe9cb2..2894b29bb1 100644 --- a/codex-rs/sandboxing/src/policy_transforms_tests.rs +++ b/codex-rs/sandboxing/src/policy_transforms_tests.rs @@ -14,7 +14,6 @@ use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::NetworkAccess; -use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use dunce::canonicalize; @@ -767,10 +766,6 @@ fn read_only_additional_permissions_can_enable_network_without_writes() { .expect("absolute temp dir"); let policy = sandbox_policy_with_additional_permissions( &SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![path.clone()], - }, network_access: false, }, &PermissionProfile { @@ -778,7 +773,7 @@ fn read_only_additional_permissions_can_enable_network_without_writes() { enabled: Some(true), }), file_system: Some(FileSystemPermissions::from_read_write_roots( - Some(vec![path.clone()]), + Some(vec![path]), Some(Vec::new()), )), }, @@ -787,10 +782,6 @@ fn read_only_additional_permissions_can_enable_network_without_writes() { assert_eq!( policy, SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![path], - }, network_access: true, } ); diff --git a/codex-rs/sandboxing/src/restricted_read_only_platform_defaults.sbpl b/codex-rs/sandboxing/src/restricted_read_only_platform_defaults.sbpl index 0e3a7bb2f2..d3015bc58a 100644 --- a/codex-rs/sandboxing/src/restricted_read_only_platform_defaults.sbpl +++ b/codex-rs/sandboxing/src/restricted_read_only_platform_defaults.sbpl @@ -1,4 +1,4 @@ -; macOS platform defaults included via `ReadOnlyAccess::Restricted::include_platform_defaults` +; macOS platform defaults included when a split filesystem policy requests `:minimal`. ; Read access to standard system paths (allow file-read* file-test-existence diff --git a/codex-rs/sandboxing/src/seatbelt_tests.rs b/codex-rs/sandboxing/src/seatbelt_tests.rs index a07e02dfc0..b691485746 100644 --- a/codex-rs/sandboxing/src/seatbelt_tests.rs +++ b/codex-rs/sandboxing/src/seatbelt_tests.rs @@ -26,7 +26,6 @@ use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::FileSystemSpecialPath; use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_protocol::protocol::ReadOnlyAccess; use codex_protocol::protocol::SandboxPolicy; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -350,43 +349,6 @@ fn seatbelt_args_without_extension_profile_keep_legacy_preferences_read_access() assert!(!policy.contains("(allow user-preference-write)")); } -#[test] -fn seatbelt_legacy_workspace_write_nested_readable_root_stays_writable() { - let tmp = TempDir::new().expect("tempdir"); - let cwd = tmp.path().join("workspace"); - fs::create_dir_all(cwd.join("docs")).expect("create docs"); - let docs = AbsolutePathBuf::from_absolute_path(cwd.join("docs")).expect("absolute docs"); - let args = create_seatbelt_command_args_for_legacy_policy( - vec!["/bin/true".to_string()], - &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], - read_only_access: ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![docs.clone()], - }, - network_access: false, - exclude_tmpdir_env_var: true, - exclude_slash_tmp: true, - }, - cwd.as_path(), - /*enforce_managed_network*/ false, - /*network*/ None, - ); - - assert!( - !args - .iter() - .any(|arg| arg.ends_with(&format!("={}", docs.as_path().display()))), - "legacy workspace-write readable roots under cwd should not become seatbelt carveouts:\n{args:#?}", - ); - assert!( - args.iter() - .any(|arg| arg.starts_with("-DWRITABLE_ROOT_0_EXCLUDED_") - && arg.ends_with("/workspace/.codex")), - "expected proactive .codex carveout for cwd root: {args:#?}", - ); -} - #[test] fn create_seatbelt_args_allows_local_binding_when_explicitly_enabled() { let policy = dynamic_network_policy( @@ -427,7 +389,6 @@ fn dynamic_network_policy_preserves_restricted_policy_when_proxy_config_without_ let policy = dynamic_network_policy( &SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -464,7 +425,6 @@ fn dynamic_network_policy_blocks_dns_when_local_binding_has_no_proxy_ports() { let policy = dynamic_network_policy( &SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -493,7 +453,6 @@ fn dynamic_network_policy_preserves_restricted_policy_for_managed_network_withou let policy = dynamic_network_policy( &SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -784,7 +743,6 @@ fn create_seatbelt_args_full_network_with_proxy_is_still_proxy_only() { let policy = dynamic_network_policy( &SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -835,7 +793,6 @@ fn create_seatbelt_args_with_read_only_git_and_codex_subpaths() { .into_iter() .map(|p| p.try_into().unwrap()) .collect(), - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1054,7 +1011,6 @@ fn create_seatbelt_args_block_first_time_dot_codex_creation_with_exact_and_desce let config_toml = dot_codex.join("config.toml"); let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![repo_root.as_path().try_into().expect("absolute repo root")], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1110,7 +1066,6 @@ fn create_seatbelt_args_with_read_only_git_pointer_file() { let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![worktree_root.try_into().expect("worktree_root is absolute")], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1206,7 +1161,6 @@ fn create_seatbelt_args_for_cwd_as_git_repo() { // `.codex` checks are done properly for cwd. let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index f4f7dede2a..6ae18ec8ab 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -182,7 +182,6 @@ pub(super) use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatu pub(super) use codex_protocol::protocol::RateLimitReachedType; pub(super) use codex_protocol::protocol::RateLimitSnapshot; pub(super) use codex_protocol::protocol::RateLimitWindow; -pub(super) use codex_protocol::protocol::ReadOnlyAccess; pub(super) use codex_protocol::protocol::RealtimeConversationClosedEvent; pub(super) use codex_protocol::protocol::RealtimeConversationRealtimeEvent; pub(super) use codex_protocol::protocol::RealtimeEvent; diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 29f9534521..73263c6871 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -54,7 +54,6 @@ async fn preset_matching_accepts_workspace_write_with_extra_roots() { let extra_root = test_path_buf("/tmp/extra").abs(); let current_sandbox = SandboxPolicy::WorkspaceWrite { writable_roots: vec![extra_root], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -537,7 +536,6 @@ async fn permissions_selection_marks_auto_review_current_with_custom_workspace_w approvals_reviewer: ApprovalsReviewer::AutoReview, sandbox_policy: SandboxPolicy::WorkspaceWrite { writable_roots: vec![extra_root], - read_only_access: ReadOnlyAccess::FullAccess, network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 3fe3e5a8af..44611deee3 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -102,7 +102,6 @@ async fn status_snapshot_includes_reasoning_details() { .sandbox_policy .set(SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -188,7 +187,6 @@ async fn status_permissions_non_default_workspace_write_is_custom() { .sandbox_policy .set(SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), - read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs b/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs index a65d6b2ce9..f9a4f5daf1 100644 --- a/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs +++ b/codex-rs/utils/sandbox-summary/src/sandbox_summary.rs @@ -23,7 +23,6 @@ pub fn summarize_sandbox_policy(sandbox_policy: &SandboxPolicy) -> String { network_access, exclude_tmpdir_env_var, exclude_slash_tmp, - read_only_access: _, } => { let mut summary = "workspace-write".to_string(); @@ -75,7 +74,6 @@ mod tests { #[test] fn summarizes_read_only_with_enabled_network() { let summary = summarize_sandbox_policy(&SandboxPolicy::ReadOnly { - access: Default::default(), network_access: true, }); assert_eq!(summary, "read-only (network access enabled)"); @@ -87,7 +85,6 @@ mod tests { let writable_root = AbsolutePathBuf::try_from(root).unwrap(); let summary = summarize_sandbox_policy(&SandboxPolicy::WorkspaceWrite { writable_roots: vec![writable_root.clone()], - read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/windows-sandbox-rs/src/allow.rs b/codex-rs/windows-sandbox-rs/src/allow.rs index b40532cda8..273dc8c4f2 100644 --- a/codex-rs/windows-sandbox-rs/src/allow.rs +++ b/codex-rs/windows-sandbox-rs/src/allow.rs @@ -110,7 +110,6 @@ mod tests { let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![AbsolutePathBuf::try_from(extra_root.as_path()).unwrap()], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -137,7 +136,6 @@ mod tests { let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: false, @@ -165,7 +163,6 @@ mod tests { let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: false, @@ -193,7 +190,6 @@ mod tests { let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: false, @@ -222,7 +218,6 @@ mod tests { let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: false, @@ -251,7 +246,6 @@ mod tests { let policy = SandboxPolicy::WorkspaceWrite { writable_roots: vec![], - read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: false, diff --git a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs index 77e4b3a896..b6e3ace1c2 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs @@ -13,6 +13,7 @@ pub struct ElevatedSandboxCaptureRequest<'a> { pub use_private_desktop: bool, pub proxy_enforced: bool, pub read_roots_override: Option<&'a [PathBuf]>, + pub read_roots_include_platform_defaults: bool, pub write_roots_override: Option<&'a [PathBuf]>, pub deny_write_paths_override: &'a [PathBuf], } @@ -121,6 +122,7 @@ mod windows_impl { use_private_desktop, proxy_enforced, read_roots_override, + read_roots_include_platform_defaults, write_roots_override, deny_write_paths_override, } = request; @@ -142,6 +144,7 @@ mod windows_impl { &env_map, codex_home, read_roots_override, + read_roots_include_platform_defaults, write_roots_override, deny_write_paths_override, proxy_enforced, @@ -250,7 +253,6 @@ mod windows_impl { fn workspace_policy(network_access: bool) -> SandboxPolicy { SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), - read_only_access: Default::default(), network_access, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/windows-sandbox-rs/src/identity.rs b/codex-rs/windows-sandbox-rs/src/identity.rs index 12b0210545..84e72341e2 100644 --- a/codex-rs/windows-sandbox-rs/src/identity.rs +++ b/codex-rs/windows-sandbox-rs/src/identity.rs @@ -137,6 +137,7 @@ pub fn require_logon_sandbox_creds( env_map: &HashMap, codex_home: &Path, read_roots_override: Option<&[PathBuf]>, + read_roots_include_platform_defaults: bool, write_roots_override: Option<&[PathBuf]>, deny_write_paths_override: &[PathBuf], proxy_enforced: bool, @@ -198,6 +199,7 @@ pub fn require_logon_sandbox_creds( }, crate::setup::SetupRootOverrides { read_roots: Some(needed_read.clone()), + read_roots_include_platform_defaults, write_roots: Some(needed_write.clone()), deny_write_paths: Some(deny_write_paths_override.to_vec()), }, @@ -216,6 +218,7 @@ pub fn require_logon_sandbox_creds( }, crate::setup::SetupRootOverrides { read_roots: Some(needed_read), + read_roots_include_platform_defaults, write_roots: Some(needed_write), deny_write_paths: Some(deny_write_paths_override.to_vec()), }, diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index b807ded589..8110c3237d 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -642,7 +642,6 @@ mod windows_impl { fn workspace_policy(network_access: bool) -> SandboxPolicy { SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), - read_only_access: Default::default(), network_access, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs index 8c334000ec..94dd3574b0 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_orchestrator.rs @@ -94,6 +94,7 @@ pub struct SandboxSetupRequest<'a> { #[derive(Default)] pub struct SetupRootOverrides { pub read_roots: Option>, + pub read_roots_include_platform_defaults: bool, pub write_roots: Option>, pub deny_write_paths: Option>, } @@ -148,6 +149,7 @@ pub fn run_setup_refresh_with_extra_read_roots( }, SetupRootOverrides { read_roots: Some(read_roots), + read_roots_include_platform_defaults: false, write_roots: Some(Vec::new()), deny_write_paths: None, }, @@ -373,38 +375,12 @@ fn gather_legacy_full_read_roots( canonical_existing(&roots) } -fn gather_restricted_read_roots( - command_cwd: &Path, - policy: &SandboxPolicy, - codex_home: &Path, -) -> Vec { - let mut roots = gather_helper_read_roots(codex_home); - if policy.include_platform_defaults() { - roots.extend( - WINDOWS_PLATFORM_DEFAULT_READ_ROOTS - .iter() - .map(PathBuf::from), - ); - } - roots.extend( - policy - .get_readable_roots_with_cwd(command_cwd) - .into_iter() - .map(|path| path.to_path_buf()), - ); - canonical_existing(&roots) -} - pub(crate) fn gather_read_roots( command_cwd: &Path, policy: &SandboxPolicy, codex_home: &Path, ) -> Vec { - if policy.has_full_disk_read_access() { - gather_legacy_full_read_roots(command_cwd, policy, codex_home) - } else { - gather_restricted_read_roots(command_cwd, policy, codex_home) - } + gather_legacy_full_read_roots(command_cwd, policy, codex_home) } pub(crate) fn gather_write_roots( @@ -792,7 +768,7 @@ fn build_payload_roots( // An explicit override is the split policy's complete readable set. Keep only the // helper/platform roots the elevated setup needs; do not re-add legacy cwd/full-read roots. let mut read_roots = gather_helper_read_roots(request.codex_home); - if request.policy.include_platform_defaults() { + if overrides.read_roots_include_platform_defaults { read_roots.extend( WINDOWS_PLATFORM_DEFAULT_READ_ROOTS .iter() @@ -973,7 +949,6 @@ mod tests { use super::proxy_ports_from_env; use crate::helper_materialization::helper_bin_dir; use crate::policy::SandboxPolicy; - use codex_protocol::protocol::ReadOnlyAccess; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::collections::HashMap; @@ -1298,66 +1273,7 @@ mod tests { } #[test] - fn restricted_read_roots_skip_platform_defaults_when_disabled() { - let tmp = TempDir::new().expect("tempdir"); - let codex_home = tmp.path().join("codex-home"); - let command_cwd = tmp.path().join("workspace"); - let readable_root = tmp.path().join("docs"); - fs::create_dir_all(&command_cwd).expect("create workspace"); - fs::create_dir_all(&readable_root).expect("create readable root"); - let policy = SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: vec![ - AbsolutePathBuf::from_absolute_path(&readable_root) - .expect("absolute readable root"), - ], - }, - network_access: false, - }; - - let roots = gather_read_roots(&command_cwd, &policy, &codex_home); - let expected_helper = - dunce::canonicalize(helper_bin_dir(&codex_home)).expect("canonical helper dir"); - let expected_cwd = dunce::canonicalize(&command_cwd).expect("canonical workspace"); - let expected_readable = - dunce::canonicalize(&readable_root).expect("canonical readable root"); - - assert!(roots.contains(&expected_helper)); - assert!(roots.contains(&expected_cwd)); - assert!(roots.contains(&expected_readable)); - assert!( - canonical_windows_platform_default_roots() - .into_iter() - .all(|path| !roots.contains(&path)) - ); - } - - #[test] - fn restricted_read_roots_include_platform_defaults_when_enabled() { - let tmp = TempDir::new().expect("tempdir"); - let codex_home = tmp.path().join("codex-home"); - let command_cwd = tmp.path().join("workspace"); - fs::create_dir_all(&command_cwd).expect("create workspace"); - let policy = SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: Vec::new(), - }, - network_access: false, - }; - - let roots = gather_read_roots(&command_cwd, &policy, &codex_home); - - assert!( - canonical_windows_platform_default_roots() - .into_iter() - .all(|path| roots.contains(&path)) - ); - } - - #[test] - fn restricted_workspace_write_roots_remain_readable() { + fn workspace_write_roots_remain_readable() { let tmp = TempDir::new().expect("tempdir"); let codex_home = tmp.path().join("codex-home"); let command_cwd = tmp.path().join("workspace"); @@ -1369,10 +1285,6 @@ mod tests { AbsolutePathBuf::from_absolute_path(&writable_root) .expect("absolute writable root"), ], - read_only_access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: Vec::new(), - }, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1385,55 +1297,6 @@ mod tests { assert!(roots.contains(&expected_writable)); } - #[test] - fn build_payload_roots_preserves_restricted_read_policy_when_no_override_is_needed() { - let tmp = TempDir::new().expect("tempdir"); - let codex_home = tmp.path().join("codex-home"); - let policy_cwd = tmp.path().join("policy-cwd"); - let command_cwd = tmp.path().join("workspace"); - let readable_root = tmp.path().join("docs"); - fs::create_dir_all(&policy_cwd).expect("create policy cwd"); - fs::create_dir_all(&command_cwd).expect("create workspace"); - fs::create_dir_all(&readable_root).expect("create readable root"); - let policy = SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: vec![ - AbsolutePathBuf::from_absolute_path(&readable_root) - .expect("absolute readable root"), - ], - }, - network_access: false, - }; - - let (read_roots, write_roots) = build_payload_roots( - &super::SandboxSetupRequest { - policy: &policy, - policy_cwd: &policy_cwd, - command_cwd: &command_cwd, - env_map: &HashMap::new(), - codex_home: &codex_home, - proxy_enforced: false, - }, - &super::SetupRootOverrides::default(), - ); - let expected_helper = - dunce::canonicalize(helper_bin_dir(&codex_home)).expect("canonical helper dir"); - let expected_cwd = dunce::canonicalize(&command_cwd).expect("canonical workspace"); - let expected_readable = - dunce::canonicalize(&readable_root).expect("canonical readable root"); - - assert_eq!(write_roots, Vec::::new()); - assert!(read_roots.contains(&expected_helper)); - assert!(read_roots.contains(&expected_cwd)); - assert!(read_roots.contains(&expected_readable)); - assert!( - canonical_windows_platform_default_roots() - .into_iter() - .all(|path| !read_roots.contains(&path)) - ); - } - #[test] fn build_payload_roots_preserves_helper_roots_when_read_override_is_provided() { let tmp = TempDir::new().expect("tempdir"); @@ -1445,10 +1308,6 @@ mod tests { fs::create_dir_all(&command_cwd).expect("create workspace"); fs::create_dir_all(&readable_root).expect("create readable root"); let policy = SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: Vec::new(), - }, network_access: false, }; @@ -1463,6 +1322,7 @@ mod tests { }, &super::SetupRootOverrides { read_roots: Some(vec![readable_root.clone()]), + read_roots_include_platform_defaults: true, write_roots: None, deny_write_paths: None, }, @@ -1495,7 +1355,6 @@ mod tests { fs::create_dir_all(&command_cwd).expect("create workspace"); fs::create_dir_all(&readable_root).expect("create readable root"); let policy = SandboxPolicy::ReadOnly { - access: ReadOnlyAccess::FullAccess, network_access: false, }; @@ -1510,6 +1369,7 @@ mod tests { }, &super::SetupRootOverrides { read_roots: Some(vec![readable_root.clone()]), + read_roots_include_platform_defaults: false, write_roots: None, deny_write_paths: None, }, @@ -1547,10 +1407,6 @@ mod tests { AbsolutePathBuf::from_absolute_path(&extra_write_root) .expect("absolute writable root"), ], - read_only_access: ReadOnlyAccess::Restricted { - include_platform_defaults: false, - readable_roots: Vec::new(), - }, network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, diff --git a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs index aab2b54462..56d04925b2 100644 --- a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs +++ b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs @@ -294,6 +294,7 @@ pub(crate) fn prepare_elevated_spawn_context( env_map, codex_home, /*read_roots_override*/ None, + /*read_roots_include_platform_defaults*/ false, write_roots_override, &deny_write_paths, /*proxy_enforced*/ false, @@ -349,7 +350,6 @@ mod tests { assert!(!should_apply_network_block( &SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), - read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, From 1c3287125fa265c68c6991437a8755e3027a52e7 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 24 Apr 2026 17:44:04 -0700 Subject: [PATCH 10/67] ci: pin codex-action v1.7 (#19472) ## Summary - update Codex issue automation to pin `openai/codex-action` to `5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02`, the commit for `v1.7` - keep the release intent visible with `# v1.7` comments beside the hash pins ## Test plan - `git diff --check` - `yq e '.' .github/workflows/issue-labeler.yml` - `yq e '.' .github/workflows/issue-deduplicator.yml` --------- Co-authored-by: Codex --- .github/workflows/issue-deduplicator.yml | 4 ++-- .github/workflows/issue-labeler.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/issue-deduplicator.yml b/.github/workflows/issue-deduplicator.yml index c0fadbcf18..17306d72e1 100644 --- a/.github/workflows/issue-deduplicator.yml +++ b/.github/workflows/issue-deduplicator.yml @@ -61,7 +61,7 @@ jobs: # .github/prompts/issue-deduplicator.txt file is obsolete and removed. - id: codex-all name: Find duplicates (pass 1, all issues) - uses: openai/codex-action@0b91f4a2703c23df3102c3f0967d3c6db34eedef # v1 + uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02 # v1.7 with: openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} allow-users: "*" @@ -195,7 +195,7 @@ jobs: - id: codex-open name: Find duplicates (pass 2, open issues) - uses: openai/codex-action@0b91f4a2703c23df3102c3f0967d3c6db34eedef # v1 + uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02 # v1.7 with: openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} allow-users: "*" diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index 80ef91ccb8..8fbaed5636 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - id: codex - uses: openai/codex-action@0b91f4a2703c23df3102c3f0967d3c6db34eedef # v1 + uses: openai/codex-action@5c3f4ccdb2b8790f73d6b21751ac00e602aa0c02 # v1.7 with: openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} allow-users: "*" From cf02e9c052764f6be6525091a8e2b0d7bb46d85d Mon Sep 17 00:00:00 2001 From: Curtis 'Fjord' Hawthorne Date: Fri, 24 Apr 2026 17:47:31 -0700 Subject: [PATCH 11/67] Fix Bazel cargo_bin runfiles paths (#19468) ## Summary Fix a Bazel-only path resolution bug in `codex_utils_cargo_bin::cargo_bin`. Under Bazel runfiles, `rlocation` can return a relative `bazel-out/...` path even though `cargo_bin()` documents that it returns an absolute path. That can break callers that store the returned binary path and later spawn it after changing cwd, because the relative path is resolved from the wrong directory. This patch absolutizes the runfiles-resolved path before returning it. --- codex-rs/utils/cargo-bin/src/lib.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/codex-rs/utils/cargo-bin/src/lib.rs b/codex-rs/utils/cargo-bin/src/lib.rs index 6517a77c94..2cb25a6b17 100644 --- a/codex-rs/utils/cargo-bin/src/lib.rs +++ b/codex-rs/utils/cargo-bin/src/lib.rs @@ -91,10 +91,15 @@ fn resolve_bin_from_env(key: &str, value: OsString) -> Result Date: Fri, 24 Apr 2026 17:49:29 -0700 Subject: [PATCH 12/67] Remove js_repl feature (#19410) --- .codespellrc | 2 +- .github/actions/prepare-bazel-ci/action.yml | 2 +- .github/actions/setup-bazel-ci/action.yml | 8 +- .github/scripts/run-bazel-ci.sh | 17 +- .github/workflows/Dockerfile.bazel | 18 +- .github/workflows/bazel.yml | 1 - .github/workflows/rust-ci-full.yml | 4 - NOTICE | 3 - codex-rs/BUILD.bazel | 1 - codex-rs/app-server/README.md | 2 +- codex-rs/config/src/config_toml.rs | 6 +- codex-rs/config/src/profile_toml.rs | 5 +- codex-rs/core/BUILD.bazel | 4 +- codex-rs/core/config.schema.json | 25 - codex-rs/core/src/agent/role.rs | 1 - codex-rs/core/src/agents_md.rs | 42 - codex-rs/core/src/agents_md_tests.rs | 34 - codex-rs/core/src/config/config_tests.rs | 8 - codex-rs/core/src/config/mod.rs | 26 - codex-rs/core/src/guardian/review_session.rs | 4 - codex-rs/core/src/original_image_detail.rs | 1 - codex-rs/core/src/session/mod.rs | 30 - codex-rs/core/src/session/review.rs | 1 - codex-rs/core/src/session/session.rs | 10 +- codex-rs/core/src/session/tests.rs | 20 +- codex-rs/core/src/session/turn_context.rs | 5 - codex-rs/core/src/tasks/mod.rs | 36 +- codex-rs/core/src/tools/code_mode/mod.rs | 3 +- codex-rs/core/src/tools/context.rs | 1 - codex-rs/core/src/tools/handlers/js_repl.rs | 300 -- .../core/src/tools/handlers/js_repl_tests.rs | 90 - codex-rs/core/src/tools/handlers/mod.rs | 3 - codex-rs/core/src/tools/js_repl/kernel.js | 1833 ----------- .../core/src/tools/js_repl/meriyah.umd.min.js | 6 - codex-rs/core/src/tools/js_repl/mod.rs | 2055 ------------ codex-rs/core/src/tools/js_repl/mod_tests.rs | 2912 ----------------- codex-rs/core/src/tools/mod.rs | 1 - codex-rs/core/src/tools/router.rs | 12 - codex-rs/core/src/tools/router_tests.rs | 172 - codex-rs/core/src/tools/spec.rs | 10 - .../core/src/tools/tool_dispatch_trace.rs | 2 - .../src/tools/tool_dispatch_trace_tests.rs | 34 - codex-rs/core/tests/suite/js_repl.rs | 795 ----- codex-rs/core/tests/suite/mod.rs | 1 - codex-rs/core/tests/suite/rmcp_client.rs | 86 - codex-rs/core/tests/suite/tools.rs | 13 +- codex-rs/core/tests/suite/view_image.rs | 229 -- codex-rs/exec/src/lib.rs | 2 - codex-rs/features/BUILD.bazel | 4 +- codex-rs/features/src/lib.rs | 24 +- codex-rs/features/src/tests.rs | 50 +- codex-rs/node-version.txt | 1 - codex-rs/tools/README.md | 1 - codex-rs/tools/src/js_repl_tool.rs | 55 - codex-rs/tools/src/js_repl_tool_tests.rs | 41 - codex-rs/tools/src/lib.rs | 3 - codex-rs/tools/src/tool_config.rs | 7 - codex-rs/tools/src/tool_registry_plan.rs | 17 - .../tools/src/tool_registry_plan_tests.rs | 63 - .../tools/src/tool_registry_plan_types.rs | 2 - .../chatwidget/tests/popups_and_settings.rs | 24 - docs/js_repl.md | 155 - third_party/meriyah/LICENSE | 15 - 63 files changed, 77 insertions(+), 9261 deletions(-) delete mode 100644 codex-rs/core/src/tools/handlers/js_repl.rs delete mode 100644 codex-rs/core/src/tools/handlers/js_repl_tests.rs delete mode 100644 codex-rs/core/src/tools/js_repl/kernel.js delete mode 100644 codex-rs/core/src/tools/js_repl/meriyah.umd.min.js delete mode 100644 codex-rs/core/src/tools/js_repl/mod.rs delete mode 100644 codex-rs/core/src/tools/js_repl/mod_tests.rs delete mode 100644 codex-rs/core/tests/suite/js_repl.rs delete mode 100644 codex-rs/node-version.txt delete mode 100644 codex-rs/tools/src/js_repl_tool.rs delete mode 100644 codex-rs/tools/src/js_repl_tool_tests.rs delete mode 100644 docs/js_repl.md delete mode 100644 third_party/meriyah/LICENSE diff --git a/.codespellrc b/.codespellrc index 87e3468c66..838b7e874e 100644 --- a/.codespellrc +++ b/.codespellrc @@ -1,6 +1,6 @@ [codespell] # Ref: https://github.com/codespell-project/codespell#using-a-config-file -skip = .git*,vendor,*-lock.yaml,*.lock,.codespellrc,*test.ts,*.jsonl,frame*.txt,*.snap,*.snap.new,*meriyah.umd.min.js +skip = .git*,vendor,*-lock.yaml,*.lock,.codespellrc,*test.ts,*.jsonl,frame*.txt,*.snap,*.snap.new check-hidden = true ignore-regex = ^\s*"image/\S+": ".*|\b(afterAll)\b ignore-words-list = ratatui,ser,iTerm,iterm2,iterm,te,TE,PASE,SEH diff --git a/.github/actions/prepare-bazel-ci/action.yml b/.github/actions/prepare-bazel-ci/action.yml index 78f5aeb9a3..48c6ba74b4 100644 --- a/.github/actions/prepare-bazel-ci/action.yml +++ b/.github/actions/prepare-bazel-ci/action.yml @@ -8,7 +8,7 @@ inputs: description: Logical namespace used to keep concurrent Bazel jobs from reserving the same repository cache key. required: true install-test-prereqs: - description: Install Node.js and DotSlash for Bazel-backed test jobs. + description: Install DotSlash for Bazel-backed test jobs. required: false default: "false" outputs: diff --git a/.github/actions/setup-bazel-ci/action.yml b/.github/actions/setup-bazel-ci/action.yml index 008e87c496..881209fd81 100644 --- a/.github/actions/setup-bazel-ci/action.yml +++ b/.github/actions/setup-bazel-ci/action.yml @@ -5,7 +5,7 @@ inputs: description: Target triple used for cache namespacing. required: true install-test-prereqs: - description: Install Node.js and DotSlash for Bazel-backed test jobs. + description: Install DotSlash for Bazel-backed test jobs. required: false default: "false" outputs: @@ -16,12 +16,6 @@ outputs: runs: using: composite steps: - - name: Set up Node.js for js_repl tests - if: inputs.install-test-prereqs == 'true' - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6 - with: - node-version-file: codex-rs/node-version.txt - # Some integration tests rely on DotSlash being installed. # See https://github.com/openai/codex/pull/7617. - name: Install DotSlash diff --git a/.github/scripts/run-bazel-ci.sh b/.github/scripts/run-bazel-ci.sh index cf2d4ce340..b81e0a4d57 100755 --- a/.github/scripts/run-bazel-ci.sh +++ b/.github/scripts/run-bazel-ci.sh @@ -4,7 +4,6 @@ set -euo pipefail print_failed_bazel_test_logs=0 print_failed_bazel_action_summary=0 -use_node_test_env=0 remote_download_toplevel=0 windows_msvc_host_platform=0 @@ -18,10 +17,6 @@ while [[ $# -gt 0 ]]; do print_failed_bazel_action_summary=1 shift ;; - --use-node-test-env) - use_node_test_env=1 - shift - ;; --remote-download-toplevel) remote_download_toplevel=1 shift @@ -42,7 +37,7 @@ while [[ $# -gt 0 ]]; do done if [[ $# -eq 0 ]]; then - echo "Usage: $0 [--print-failed-test-logs] [--print-failed-action-summary] [--use-node-test-env] [--remote-download-toplevel] [--windows-msvc-host-platform] -- -- " >&2 + echo "Usage: $0 [--print-failed-test-logs] [--print-failed-action-summary] [--remote-download-toplevel] [--windows-msvc-host-platform] -- -- " >&2 exit 1 fi @@ -249,16 +244,6 @@ if [[ ${#bazel_args[@]} -eq 0 || ${#bazel_targets[@]} -eq 0 ]]; then exit 1 fi -if [[ $use_node_test_env -eq 1 ]]; then - # Bazel test sandboxes on macOS may resolve an older Homebrew `node` - # before the `actions/setup-node` runtime on PATH. - node_bin="$(which node)" - if [[ "${RUNNER_OS:-}" == "Windows" ]]; then - node_bin="$(cygpath -w "${node_bin}")" - fi - bazel_args+=("--test_env=CODEX_JS_REPL_NODE_PATH=${node_bin}") -fi - post_config_bazel_args=() if [[ "${RUNNER_OS:-}" == "Windows" && $windows_msvc_host_platform -eq 1 ]]; then has_host_platform_override=0 diff --git a/.github/workflows/Dockerfile.bazel b/.github/workflows/Dockerfile.bazel index 4f85409f94..51c199dcc3 100644 --- a/.github/workflows/Dockerfile.bazel +++ b/.github/workflows/Dockerfile.bazel @@ -8,25 +8,9 @@ FROM ubuntu:24.04 RUN apt-get update && \ apt-get install -y --no-install-recommends \ - curl git python3 ca-certificates xz-utils && \ + curl git python3 ca-certificates && \ rm -rf /var/lib/apt/lists/* -COPY codex-rs/node-version.txt /tmp/node-version.txt - -RUN set -eux; \ - node_arch="$(dpkg --print-architecture)"; \ - case "${node_arch}" in \ - amd64) node_dist_arch="x64" ;; \ - arm64) node_dist_arch="arm64" ;; \ - *) echo "unsupported architecture: ${node_arch}"; exit 1 ;; \ - esac; \ - node_version="$(tr -d '[:space:]' , - /// Optional absolute path to the Node runtime used by `js_repl`. + /// Deprecated: ignored. + #[schemars(skip)] pub js_repl_node_path: Option, - /// Ordered list of directories to search for Node modules in `js_repl`. + /// Deprecated: ignored. + #[schemars(skip)] pub js_repl_node_module_dirs: Option>, /// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution. diff --git a/codex-rs/config/src/profile_toml.rs b/codex-rs/config/src/profile_toml.rs index 642770ff7e..f6f63191b5 100644 --- a/codex-rs/config/src/profile_toml.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -41,8 +41,11 @@ pub struct ConfigProfile { pub chatgpt_base_url: Option, /// Optional path to a file containing model instructions. pub model_instructions_file: Option, + /// Deprecated: ignored. + #[schemars(skip)] pub js_repl_node_path: Option, - /// Ordered list of directories to search for Node modules in `js_repl`. + /// Deprecated: ignored. + #[schemars(skip)] pub js_repl_node_module_dirs: Option>, /// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution. pub zsh_path: Option, diff --git a/codex-rs/core/BUILD.bazel b/codex-rs/core/BUILD.bazel index df5f4da1fa..cfa077ff17 100644 --- a/codex-rs/core/BUILD.bazel +++ b/codex-rs/core/BUILD.bazel @@ -19,9 +19,7 @@ codex_rust_crate( "Cargo.toml", ], allow_empty = True, - ) + [ - "//codex-rs:node-version.txt", - ], + ), rustc_env = { # Keep manifest-root path lookups inside the Bazel execroot for code # that relies on env!("CARGO_MANIFEST_DIR"). diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 030c36a8b6..a009fc2a55 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -583,16 +583,6 @@ "include_permissions_instructions": { "type": "boolean" }, - "js_repl_node_module_dirs": { - "description": "Ordered list of directories to search for Node modules in `js_repl`.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "js_repl_node_path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, "model": { "type": "string" }, @@ -2849,21 +2839,6 @@ "description": "System instructions.", "type": "string" }, - "js_repl_node_module_dirs": { - "description": "Ordered list of directories to search for Node modules in `js_repl`.", - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" - }, - "js_repl_node_path": { - "allOf": [ - { - "$ref": "#/definitions/AbsolutePathBuf" - } - ], - "description": "Optional absolute path to the Node runtime used by `js_repl`." - }, "log_dir": { "allOf": [ { diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 9569c02d71..0ee1de760c 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -267,7 +267,6 @@ mod reload { model_provider: preserve_current_provider.then(|| config.model_provider_id.clone()), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), - js_repl_node_path: config.js_repl_node_path.clone(), ..Default::default() } } diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 5828354647..b7fb7b11ce 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -42,41 +42,6 @@ pub const LOCAL_AGENTS_MD_FILENAME: &str = "AGENTS.override.md"; /// be concatenated with the following separator. const AGENTS_MD_SEPARATOR: &str = "\n\n--- project-doc ---\n\n"; -fn render_js_repl_instructions(config: &Config) -> Option { - if !config.features.enabled(Feature::JsRepl) { - return None; - } - - let mut section = String::from("## JavaScript REPL (Node)\n"); - section.push_str( - "- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n", - ); - section.push_str("- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n"); - section.push_str( - "- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n", - ); - section.push_str("- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n"); - section.push_str("- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }` containing encoded PNG/JPEG/WebP/GIF bytes, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n"); - section.push_str("- `codex.tool(...)` and `codex.emitImage(...)` keep stable helper identities across cells. Saved references and persisted objects can reuse them in later cells, but async callbacks that fire after a cell finishes still fail because no exec is active.\n"); - section.push_str("- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n"); - section.push_str("- Raw MCP image blocks can request the same behavior by returning `_meta: { \"codex/imageDetail\": \"original\" }` on the image content item.\n"); - section.push_str("- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n"); - section.push_str("- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n"); - section.push_str("- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n"); - section.push_str("- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n"); - section.push_str("- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n"); - - if config.features.enabled(Feature::JsReplToolsOnly) { - section.push_str("- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n"); - section - .push_str("- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n"); - } - - section.push_str("- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`."); - - Some(section) -} - /// Resolves AGENTS.md files into model-visible user instructions and source /// paths. pub struct AgentsMdManager<'a> { @@ -147,13 +112,6 @@ impl<'a> AgentsMdManager<'a> { } }; - if let Some(js_repl_section) = render_js_repl_instructions(self.config) { - if !output.is_empty() { - output.push_str("\n\n"); - } - output.push_str(&js_repl_section); - } - if self.config.features.enabled(Feature::ChildAgentsMd) { if !output.is_empty() { output.push_str("\n\n"); diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index e163eba1f6..a3a7544823 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -199,40 +199,6 @@ async fn zero_byte_limit_disables_discovery() { assert_eq!(discovery, Vec::::new()); } -#[tokio::test] -async fn js_repl_instructions_are_appended_when_enabled() { - let tmp = tempfile::tempdir().expect("tempdir"); - let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; - cfg.features - .enable(Feature::JsRepl) - .expect("test config should allow js_repl"); - - let res = get_user_instructions(&cfg) - .await - .expect("js_repl instructions expected"); - let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }` containing encoded PNG/JPEG/WebP/GIF bytes, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- `codex.tool(...)` and `codex.emitImage(...)` keep stable helper identities across cells. Saved references and persisted objects can reuse them in later cells, but async callbacks that fire after a cell finishes still fail because no exec is active.\n- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n- Raw MCP image blocks can request the same behavior by returning `_meta: { \"codex/imageDetail\": \"original\" }` on the image content item.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`."; - assert_eq!(res, expected); -} - -#[tokio::test] -async fn js_repl_tools_only_instructions_are_feature_gated() { - let tmp = tempfile::tempdir().expect("tempdir"); - let mut cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await; - let mut features = cfg.features.get().clone(); - features - .enable(Feature::JsRepl) - .enable(Feature::JsReplToolsOnly); - cfg.features - .set(features) - .expect("test config should allow js_repl tool restrictions"); - - let res = get_user_instructions(&cfg) - .await - .expect("js_repl instructions expected"); - let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }` containing encoded PNG/JPEG/WebP/GIF bytes, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- `codex.tool(...)` and `codex.emitImage(...)` keep stable helper identities across cells. Saved references and persisted objects can reuse them in later cells, but async callbacks that fire after a cell finishes still fail because no exec is active.\n- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n- Raw MCP image blocks can request the same behavior by returning `_meta: { \"codex/imageDetail\": \"original\" }` on the image content item.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`."; - assert_eq!(res, expected); -} - /// When both system instructions and AGENTS.md docs are present the two /// should be concatenated with the separator. #[tokio::test] diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7ea9f27197..37815411c1 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -5260,8 +5260,6 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { codex_self_exe: None, codex_linux_sandbox_exe: None, main_execve_wrapper_exe: None, - js_repl_node_path: None, - js_repl_node_module_dirs: Vec::new(), zsh_path: None, hide_agent_reasoning: false, show_raw_agent_reasoning: false, @@ -5458,8 +5456,6 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { codex_self_exe: None, codex_linux_sandbox_exe: None, main_execve_wrapper_exe: None, - js_repl_node_path: None, - js_repl_node_module_dirs: Vec::new(), zsh_path: None, hide_agent_reasoning: false, show_raw_agent_reasoning: false, @@ -5610,8 +5606,6 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { codex_self_exe: None, codex_linux_sandbox_exe: None, main_execve_wrapper_exe: None, - js_repl_node_path: None, - js_repl_node_module_dirs: Vec::new(), zsh_path: None, hide_agent_reasoning: false, show_raw_agent_reasoning: false, @@ -5747,8 +5741,6 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { codex_self_exe: None, codex_linux_sandbox_exe: None, main_execve_wrapper_exe: None, - js_repl_node_path: None, - js_repl_node_module_dirs: Vec::new(), zsh_path: None, hide_agent_reasoning: false, show_raw_agent_reasoning: false, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 33fe18d1f4..11ae66de01 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -495,12 +495,6 @@ pub struct Config { /// code via [`ConfigOverrides`]. pub main_execve_wrapper_exe: Option, - /// Optional absolute path to the Node runtime used by `js_repl`. - pub js_repl_node_path: Option, - - /// Ordered list of directories to search for Node modules in `js_repl`. - pub js_repl_node_module_dirs: Vec, - /// Optional absolute path to patched zsh used by zsh-exec-bridge-backed shell execution. pub zsh_path: Option, @@ -1422,8 +1416,6 @@ pub struct ConfigOverrides { pub codex_self_exe: Option, pub codex_linux_sandbox_exe: Option, pub main_execve_wrapper_exe: Option, - pub js_repl_node_path: Option, - pub js_repl_node_module_dirs: Option>, pub zsh_path: Option, pub base_instructions: Option, pub developer_instructions: Option, @@ -1642,8 +1634,6 @@ impl Config { codex_self_exe, codex_linux_sandbox_exe, main_execve_wrapper_exe, - js_repl_node_path: js_repl_node_path_override, - js_repl_node_module_dirs: js_repl_node_module_dirs_override, zsh_path: zsh_path_override, base_instructions, developer_instructions, @@ -2177,20 +2167,6 @@ impl Config { ) .await?; let compact_prompt = compact_prompt.or(file_compact_prompt); - let js_repl_node_path = js_repl_node_path_override - .or(config_profile.js_repl_node_path.map(Into::into)) - .or(cfg.js_repl_node_path.map(Into::into)); - let js_repl_node_module_dirs = js_repl_node_module_dirs_override - .or_else(|| { - config_profile - .js_repl_node_module_dirs - .map(|dirs| dirs.into_iter().map(Into::into).collect::>()) - }) - .or_else(|| { - cfg.js_repl_node_module_dirs - .map(|dirs| dirs.into_iter().map(Into::into).collect::>()) - }) - .unwrap_or_default(); let zsh_path = zsh_path_override .or(config_profile.zsh_path.map(Into::into)) .or(cfg.zsh_path.map(Into::into)); @@ -2414,8 +2390,6 @@ impl Config { codex_self_exe, codex_linux_sandbox_exe, main_execve_wrapper_exe, - js_repl_node_path, - js_repl_node_module_dirs, zsh_path, hide_agent_reasoning: cfg.hide_agent_reasoning.unwrap_or(false), diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index db372c6944..429bdce5ec 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -148,8 +148,6 @@ struct GuardianReviewSessionReuseKey { mcp_servers: Constrained>, codex_linux_sandbox_exe: Option, main_execve_wrapper_exe: Option, - js_repl_node_path: Option, - js_repl_node_module_dirs: Vec, zsh_path: Option, features: ManagedFeatures, include_apply_patch_tool: bool, @@ -175,8 +173,6 @@ impl GuardianReviewSessionReuseKey { mcp_servers: spawn_config.mcp_servers.clone(), codex_linux_sandbox_exe: spawn_config.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: spawn_config.main_execve_wrapper_exe.clone(), - js_repl_node_path: spawn_config.js_repl_node_path.clone(), - js_repl_node_module_dirs: spawn_config.js_repl_node_module_dirs.clone(), zsh_path: spawn_config.zsh_path.clone(), features: spawn_config.features.clone(), include_apply_patch_tool: spawn_config.include_apply_patch_tool, diff --git a/codex-rs/core/src/original_image_detail.rs b/codex-rs/core/src/original_image_detail.rs index adfed321b8..47d57d9a47 100644 --- a/codex-rs/core/src/original_image_detail.rs +++ b/codex-rs/core/src/original_image_detail.rs @@ -1,3 +1,2 @@ pub(crate) use codex_tools::can_request_original_image_detail; -pub(crate) use codex_tools::normalize_output_image_detail; pub(crate) use codex_tools::sanitize_original_image_detail; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index b643be065f..71003e6a03 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -293,8 +293,6 @@ use crate::tasks::GhostSnapshotTask; use crate::tasks::ReviewTask; use crate::tasks::SessionTask; use crate::tasks::SessionTaskContext; -use crate::tools::js_repl::JsReplHandle; -use crate::tools::js_repl::resolve_compatible_node; use crate::tools::network_approval::NetworkApprovalService; use crate::tools::network_approval::build_blocked_request_observer; use crate::tools::network_approval::build_network_policy_decider; @@ -500,34 +498,6 @@ impl Codex { let _ = config.features.disable(Feature::Collab); } - if config.features.enabled(Feature::JsRepl) - && let Err(err) = resolve_compatible_node(config.js_repl_node_path.as_deref()).await - { - let _ = config.features.disable(Feature::JsRepl); - let _ = config.features.disable(Feature::JsReplToolsOnly); - let message = if config.features.enabled(Feature::JsRepl) { - format!( - "`js_repl` remains enabled because enterprise requirements pin it on, but the configured Node runtime is unavailable or incompatible. {err}" - ) - } else { - format!( - "Disabled `js_repl` for this session because the configured Node runtime is unavailable or incompatible. {err}" - ) - }; - warn!("{message}"); - config.startup_warnings.push(message); - } - if config.features.enabled(Feature::CodeMode) - && let Err(err) = resolve_compatible_node(config.js_repl_node_path.as_deref()).await - { - let message = format!( - "Disabled `exec` for this session because the configured Node runtime is unavailable or incompatible. {err}" - ); - warn!("{message}"); - let _ = config.features.disable(Feature::CodeMode); - config.startup_warnings.push(message); - } - let user_instructions = AgentsMdManager::new(&config) .user_instructions(environment.as_deref()) .await; diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 4a995d85ed..9d502ab1d7 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -136,7 +136,6 @@ pub(super) async fn spawn_review_thread( codex_self_exe: parent_turn_context.codex_self_exe.clone(), codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(), tool_call_gate: Arc::new(ReadinessFlag::new()), - js_repl: Arc::clone(&sess.js_repl), dynamic_tools: parent_turn_context.dynamic_tools.clone(), truncation_policy: model_info.truncation_policy.into(), turn_metadata_state, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index cbc060b0ce..1773b256e0 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -25,7 +25,6 @@ pub(crate) struct Session { pub(super) idle_pending_input: Mutex>, // TODO (jif) merge with mailbox! pub(crate) guardian_review_session: GuardianReviewSessionManager, pub(crate) services: SessionServices, - pub(super) js_repl: Arc, pub(super) next_internal_sub_id: AtomicU64, } @@ -766,18 +765,12 @@ impl Session { config.features.enabled(Feature::RuntimeMetrics), Self::build_model_client_beta_features_header(config.as_ref()), ), - code_mode_service: crate::tools::code_mode::CodeModeService::new( - config.js_repl_node_path.clone(), - ), + code_mode_service: crate::tools::code_mode::CodeModeService::new(), environment_manager, }; services .model_client .set_window_generation(window_generation); - let js_repl = Arc::new(JsReplHandle::with_node_path( - config.js_repl_node_path.clone(), - config.js_repl_node_module_dirs.clone(), - )); let (out_of_band_elicitation_paused, _out_of_band_elicitation_paused_rx) = watch::channel(false); @@ -798,7 +791,6 @@ impl Session { idle_pending_input: Mutex::new(Vec::new()), guardian_review_session: GuardianReviewSessionManager::default(), services, - js_repl, next_internal_sub_id: AtomicU64::new(0), }); if let Some(network_policy_decider_session) = network_policy_decider_session { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index f29f6c888f..677f6d7a49 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3311,15 +3311,9 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { config.features.enabled(Feature::RuntimeMetrics), Session::build_model_client_beta_features_header(config.as_ref()), ), - code_mode_service: crate::tools::code_mode::CodeModeService::new( - config.js_repl_node_path.clone(), - ), + code_mode_service: crate::tools::code_mode::CodeModeService::new(), environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), }; - let js_repl = Arc::new(JsReplHandle::with_node_path( - config.js_repl_node_path.clone(), - config.js_repl_node_module_dirs.clone(), - )); let plugin_outcome = services .plugins_manager @@ -3353,7 +3347,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { turn_environments, session_configuration.cwd.clone(), "turn_id".to_string(), - Arc::clone(&js_repl), skills_outcome, ); @@ -3374,7 +3367,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { idle_pending_input: Mutex::new(Vec::new()), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, - js_repl, next_internal_sub_id: AtomicU64::new(0), }; @@ -4674,15 +4666,9 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( config.features.enabled(Feature::RuntimeMetrics), Session::build_model_client_beta_features_header(config.as_ref()), ), - code_mode_service: crate::tools::code_mode::CodeModeService::new( - config.js_repl_node_path.clone(), - ), + code_mode_service: crate::tools::code_mode::CodeModeService::new(), environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), }; - let js_repl = Arc::new(JsReplHandle::with_node_path( - config.js_repl_node_path.clone(), - config.js_repl_node_module_dirs.clone(), - )); let plugin_outcome = services .plugins_manager @@ -4716,7 +4702,6 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( turn_environments, session_configuration.cwd.clone(), "turn_id".to_string(), - Arc::clone(&js_repl), skills_outcome, )); @@ -4737,7 +4722,6 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( idle_pending_input: Mutex::new(Vec::new()), guardian_review_session: crate::guardian::GuardianReviewSessionManager::default(), services, - js_repl, next_internal_sub_id: AtomicU64::new(0), }); diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index f3ca9d37b9..d2e6b5a214 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -87,7 +87,6 @@ pub(crate) struct TurnContext { pub(crate) codex_linux_sandbox_exe: Option, pub(crate) tool_call_gate: Arc, pub(crate) truncation_policy: TruncationPolicy, - pub(crate) js_repl: Arc, pub(crate) dynamic_tools: Vec, pub(crate) turn_metadata_state: Arc, pub(crate) turn_skills: TurnSkillsContext, @@ -227,7 +226,6 @@ impl TurnContext { codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), tool_call_gate: Arc::new(ReadinessFlag::new()), truncation_policy, - js_repl: Arc::clone(&self.js_repl), dynamic_tools: self.dynamic_tools.clone(), turn_metadata_state: self.turn_metadata_state.clone(), turn_skills: self.turn_skills.clone(), @@ -406,7 +404,6 @@ impl Session { environments: Vec, cwd: AbsolutePathBuf, sub_id: String, - js_repl: Arc, skills_outcome: Arc, ) -> TurnContext { let reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); @@ -497,7 +494,6 @@ impl Session { codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(), tool_call_gate: Arc::new(ReadinessFlag::new()), truncation_policy: model_info.truncation_policy.into(), - js_repl, dynamic_tools: session_configuration.dynamic_tools.clone(), turn_metadata_state, turn_skills: TurnSkillsContext::new(skills_outcome), @@ -682,7 +678,6 @@ impl Session { turn_environments, cwd, sub_id, - Arc::clone(&self.js_repl), skills_outcome, ); turn_context.realtime_active = self.conversation.running_state().await.is_some(); diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b0ec96cfed..f981b62ba7 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -677,14 +677,6 @@ impl Session { .await; } - pub(crate) async fn cleanup_after_interrupt(&self, turn_context: &Arc) { - if let Some(manager) = turn_context.js_repl.manager_if_initialized() - && let Err(err) = manager.interrupt_turn_exec(&turn_context.sub_id).await - { - warn!("failed to interrupt js_repl kernel: {err}"); - } - } - async fn handle_task_abort(self: &Arc, task: RunningTask, reason: TurnAbortReason) { let sub_id = task.turn_context.sub_id.clone(); if task.cancellation_token.is_cancelled() { @@ -713,23 +705,19 @@ impl Session { .abort(session_ctx, Arc::clone(&task.turn_context)) .await; - if reason == TurnAbortReason::Interrupted { - self.cleanup_after_interrupt(&task.turn_context).await; - - if let Some(marker) = interrupted_turn_history_marker( + if reason == TurnAbortReason::Interrupted + && let Some(marker) = interrupted_turn_history_marker( InterruptedTurnHistoryMarker::from_config(task.turn_context.config.as_ref()), - ) { - self.record_into_history(std::slice::from_ref(&marker), task.turn_context.as_ref()) - .await; - self.persist_rollout_items(&[RolloutItem::ResponseItem(marker)]) - .await; - // Ensure the marker is durably visible before emitting TurnAborted: some clients - // synchronously re-read the rollout on receipt of the abort event. - if let Err(err) = self.flush_rollout().await { - warn!( - "failed to flush interrupted-turn marker before emitting TurnAborted: {err}" - ); - } + ) + { + self.record_into_history(std::slice::from_ref(&marker), task.turn_context.as_ref()) + .await; + self.persist_rollout_items(&[RolloutItem::ResponseItem(marker)]) + .await; + // Ensure the marker is durably visible before emitting TurnAborted: some clients + // synchronously re-read the rollout on receipt of the abort event. + if let Err(err) = self.flush_rollout().await { + warn!("failed to flush interrupted-turn marker before emitting TurnAborted: {err}"); } } diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs index 8032b9f318..0bfd080ae0 100644 --- a/codex-rs/core/src/tools/code_mode/mod.rs +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -3,7 +3,6 @@ mod response_adapter; mod wait_handler; use std::collections::HashSet; -use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -62,7 +61,7 @@ pub(crate) struct CodeModeService { } impl CodeModeService { - pub(crate) fn new(_js_repl_node_path: Option) -> Self { + pub(crate) fn new() -> Self { Self { inner: codex_code_mode::CodeModeService::new(), } diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 89aef248a6..f65baeb6dc 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -33,7 +33,6 @@ pub type SharedTurnDiffTracker = Arc>; #[derive(Clone, Debug, Eq, PartialEq)] pub enum ToolCallSource { Direct, - JsRepl, CodeMode { /// Runtime cell that issued the nested tool request. cell_id: String, diff --git a/codex-rs/core/src/tools/handlers/js_repl.rs b/codex-rs/core/src/tools/handlers/js_repl.rs deleted file mode 100644 index 906e1bb637..0000000000 --- a/codex-rs/core/src/tools/handlers/js_repl.rs +++ /dev/null @@ -1,300 +0,0 @@ -use serde_json::Value as JsonValue; -use std::sync::Arc; -use std::time::Duration; -use std::time::Instant; - -use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::events::ToolEmitter; -use crate::tools::events::ToolEventCtx; -use crate::tools::events::ToolEventFailure; -use crate::tools::events::ToolEventStage; -use crate::tools::handlers::parse_arguments; -use crate::tools::js_repl::JS_REPL_PRAGMA_PREFIX; -use crate::tools::js_repl::JsReplArgs; -use crate::tools::registry::ToolHandler; -use crate::tools::registry::ToolKind; -use codex_features::Feature; -use codex_protocol::exec_output::ExecToolCallOutput; -use codex_protocol::exec_output::StreamOutput; -use codex_protocol::models::FunctionCallOutputContentItem; -use codex_protocol::protocol::ExecCommandSource; - -pub struct JsReplHandler; -pub struct JsReplResetHandler; - -fn join_outputs(stdout: &str, stderr: &str) -> String { - if stdout.is_empty() { - stderr.to_string() - } else if stderr.is_empty() { - stdout.to_string() - } else { - format!("{stdout}\n{stderr}") - } -} - -fn build_js_repl_exec_output( - output: &str, - error: Option<&str>, - duration: Duration, -) -> ExecToolCallOutput { - let stdout = output.to_string(); - let stderr = error.unwrap_or("").to_string(); - let aggregated_output = join_outputs(&stdout, &stderr); - ExecToolCallOutput { - exit_code: if error.is_some() { 1 } else { 0 }, - stdout: StreamOutput::new(stdout), - stderr: StreamOutput::new(stderr), - aggregated_output: StreamOutput::new(aggregated_output), - duration, - timed_out: false, - } -} - -async fn emit_js_repl_exec_begin( - session: &crate::session::session::Session, - turn: &crate::session::turn_context::TurnContext, - call_id: &str, -) { - let emitter = ToolEmitter::shell( - vec!["js_repl".to_string()], - turn.cwd.clone(), - ExecCommandSource::Agent, - /*freeform*/ false, - ); - let ctx = ToolEventCtx::new(session, turn, call_id, /*turn_diff_tracker*/ None); - emitter.emit(ctx, ToolEventStage::Begin).await; -} - -async fn emit_js_repl_exec_end( - session: &crate::session::session::Session, - turn: &crate::session::turn_context::TurnContext, - call_id: &str, - output: &str, - error: Option<&str>, - duration: Duration, -) { - let exec_output = build_js_repl_exec_output(output, error, duration); - let emitter = ToolEmitter::shell( - vec!["js_repl".to_string()], - turn.cwd.clone(), - ExecCommandSource::Agent, - /*freeform*/ false, - ); - let ctx = ToolEventCtx::new(session, turn, call_id, /*turn_diff_tracker*/ None); - let stage = if error.is_some() { - ToolEventStage::Failure(ToolEventFailure::Output(exec_output)) - } else { - ToolEventStage::Success(exec_output) - }; - emitter.emit(ctx, stage).await; -} -impl ToolHandler for JsReplHandler { - type Output = FunctionToolOutput; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - fn matches_kind(&self, payload: &ToolPayload) -> bool { - matches!( - payload, - ToolPayload::Function { .. } | ToolPayload::Custom { .. } - ) - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - cancellation_token, - tracker, - payload, - call_id, - .. - } = invocation; - - if !session.features().enabled(Feature::JsRepl) { - return Err(FunctionCallError::RespondToModel( - "js_repl is disabled by feature flag".to_string(), - )); - } - - let args = match payload { - ToolPayload::Function { arguments } => parse_arguments(&arguments)?, - ToolPayload::Custom { input } => parse_freeform_args(&input)?, - _ => { - return Err(FunctionCallError::RespondToModel( - "js_repl expects custom or function payload".to_string(), - )); - } - }; - let manager = turn.js_repl.manager().await?; - let started_at = Instant::now(); - emit_js_repl_exec_begin(session.as_ref(), turn.as_ref(), &call_id).await; - let result = manager - .execute_with_cancellation( - Arc::clone(&session), - Arc::clone(&turn), - cancellation_token, - tracker, - args, - ) - .await; - let result = match result { - Ok(result) => result, - Err(err) => { - let message = err.to_string(); - emit_js_repl_exec_end( - session.as_ref(), - turn.as_ref(), - &call_id, - "", - Some(&message), - started_at.elapsed(), - ) - .await; - return Err(err); - } - }; - - let content = result.output; - let mut items = Vec::with_capacity(result.content_items.len() + 1); - if !content.is_empty() { - items.push(FunctionCallOutputContentItem::InputText { - text: content.clone(), - }); - } - items.extend(result.content_items); - - emit_js_repl_exec_end( - session.as_ref(), - turn.as_ref(), - &call_id, - &content, - /*error*/ None, - started_at.elapsed(), - ) - .await; - - if items.is_empty() { - Ok(FunctionToolOutput::from_text(content, Some(true))) - } else { - Ok(FunctionToolOutput::from_content(items, Some(true))) - } - } -} - -impl ToolHandler for JsReplResetHandler { - type Output = FunctionToolOutput; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - if !invocation.session.features().enabled(Feature::JsRepl) { - return Err(FunctionCallError::RespondToModel( - "js_repl is disabled by feature flag".to_string(), - )); - } - let manager = invocation.turn.js_repl.manager().await?; - manager.reset().await?; - Ok(FunctionToolOutput::from_text( - "js_repl kernel reset".to_string(), - Some(true), - )) - } -} - -fn parse_freeform_args(input: &str) -> Result { - if input.trim().is_empty() { - return Err(FunctionCallError::RespondToModel( - "js_repl expects raw JavaScript tool input (non-empty). Provide JS source text, optionally with first-line `// codex-js-repl: ...`." - .to_string(), - )); - } - - let mut args = JsReplArgs { - code: input.to_string(), - timeout_ms: None, - }; - - let mut lines = input.splitn(2, '\n'); - let first_line = lines.next().unwrap_or_default(); - let rest = lines.next().unwrap_or_default(); - let trimmed = first_line.trim_start(); - let Some(pragma) = trimmed.strip_prefix(JS_REPL_PRAGMA_PREFIX) else { - reject_json_or_quoted_source(&args.code)?; - return Ok(args); - }; - - let mut timeout_ms: Option = None; - let directive = pragma.trim(); - if !directive.is_empty() { - for token in directive.split_whitespace() { - let (key, value) = token.split_once('=').ok_or_else(|| { - FunctionCallError::RespondToModel(format!( - "js_repl pragma expects space-separated key=value pairs (supported keys: timeout_ms); got `{token}`" - )) - })?; - match key { - "timeout_ms" => { - if timeout_ms.is_some() { - return Err(FunctionCallError::RespondToModel( - "js_repl pragma specifies timeout_ms more than once".to_string(), - )); - } - let parsed = value.parse::().map_err(|_| { - FunctionCallError::RespondToModel(format!( - "js_repl pragma timeout_ms must be an integer; got `{value}`" - )) - })?; - timeout_ms = Some(parsed); - } - _ => { - return Err(FunctionCallError::RespondToModel(format!( - "js_repl pragma only supports timeout_ms; got `{key}`" - ))); - } - } - } - } - - if rest.trim().is_empty() { - return Err(FunctionCallError::RespondToModel( - "js_repl pragma must be followed by JavaScript source on subsequent lines".to_string(), - )); - } - - reject_json_or_quoted_source(rest)?; - args.code = rest.to_string(); - args.timeout_ms = timeout_ms; - Ok(args) -} - -fn reject_json_or_quoted_source(code: &str) -> Result<(), FunctionCallError> { - let trimmed = code.trim(); - if trimmed.starts_with("```") { - return Err(FunctionCallError::RespondToModel( - "js_repl expects raw JavaScript source, not markdown code fences. Resend plain JS only (optional first line `// codex-js-repl: ...`)." - .to_string(), - )); - } - let Ok(value) = serde_json::from_str::(trimmed) else { - return Ok(()); - }; - match value { - JsonValue::Object(_) | JsonValue::String(_) => Err(FunctionCallError::RespondToModel( - "js_repl is a freeform tool and expects raw JavaScript source. Resend plain JS only (optional first line `// codex-js-repl: ...`); do not send JSON (`{\"code\":...}`), quoted code, or markdown fences." - .to_string(), - )), - _ => Ok(()), - } -} - -#[cfg(test)] -#[path = "js_repl_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/tools/handlers/js_repl_tests.rs b/codex-rs/core/src/tools/handlers/js_repl_tests.rs deleted file mode 100644 index 0f3274409b..0000000000 --- a/codex-rs/core/src/tools/handlers/js_repl_tests.rs +++ /dev/null @@ -1,90 +0,0 @@ -use std::time::Duration; - -use super::parse_freeform_args; -use crate::session::tests::make_session_and_context_with_rx; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::ExecCommandSource; -use pretty_assertions::assert_eq; - -#[test] -fn parse_freeform_args_without_pragma() { - let args = parse_freeform_args("console.log('ok');").expect("parse args"); - assert_eq!(args.code, "console.log('ok');"); - assert_eq!(args.timeout_ms, None); -} - -#[test] -fn parse_freeform_args_with_pragma() { - let input = "// codex-js-repl: timeout_ms=15000\nconsole.log('ok');"; - let args = parse_freeform_args(input).expect("parse args"); - assert_eq!(args.code, "console.log('ok');"); - assert_eq!(args.timeout_ms, Some(15_000)); -} - -#[test] -fn parse_freeform_args_rejects_unknown_key() { - let err = parse_freeform_args("// codex-js-repl: nope=1\nconsole.log('ok');") - .expect_err("expected error"); - assert_eq!( - err.to_string(), - "js_repl pragma only supports timeout_ms; got `nope`" - ); -} - -#[test] -fn parse_freeform_args_rejects_reset_key() { - let err = parse_freeform_args("// codex-js-repl: reset=true\nconsole.log('ok');") - .expect_err("expected error"); - assert_eq!( - err.to_string(), - "js_repl pragma only supports timeout_ms; got `reset`" - ); -} - -#[test] -fn parse_freeform_args_rejects_json_wrapped_code() { - let err = parse_freeform_args(r#"{"code":"await doThing()"}"#).expect_err("expected error"); - assert_eq!( - err.to_string(), - "js_repl is a freeform tool and expects raw JavaScript source. Resend plain JS only (optional first line `// codex-js-repl: ...`); do not send JSON (`{\"code\":...}`), quoted code, or markdown fences." - ); -} - -#[tokio::test] -async fn emit_js_repl_exec_end_sends_event() { - let (session, turn, rx) = make_session_and_context_with_rx().await; - super::emit_js_repl_exec_end( - session.as_ref(), - turn.as_ref(), - "call-1", - "hello", - /*error*/ None, - Duration::from_millis(12), - ) - .await; - - let event = tokio::time::timeout(Duration::from_secs(5), async { - loop { - let event = rx.recv().await.expect("event"); - if let EventMsg::ExecCommandEnd(end) = event.msg { - break end; - } - } - }) - .await - .expect("timed out waiting for exec end"); - - assert_eq!(event.call_id, "call-1"); - assert_eq!(event.turn_id, turn.sub_id); - assert_eq!(event.command, vec!["js_repl".to_string()]); - assert_eq!(event.cwd, turn.cwd); - assert_eq!(event.source, ExecCommandSource::Agent); - assert_eq!(event.interaction_input, None); - assert_eq!(event.stdout, "hello"); - assert_eq!(event.stderr, ""); - assert!(event.aggregated_output.contains("hello")); - assert_eq!(event.exit_code, 0); - assert_eq!(event.duration, Duration::from_millis(12)); - assert!(event.formatted_output.contains("hello")); - assert!(!event.parsed_cmd.is_empty()); -} diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 7878c1092c..757b0d94bd 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -1,7 +1,6 @@ pub(crate) mod agent_jobs; pub(crate) mod apply_patch; mod dynamic; -mod js_repl; mod list_dir; mod mcp; mod mcp_resource; @@ -37,8 +36,6 @@ pub use apply_patch::ApplyPatchHandler; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::AskForApproval; pub use dynamic::DynamicToolHandler; -pub use js_repl::JsReplHandler; -pub use js_repl::JsReplResetHandler; pub use list_dir::ListDirHandler; pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; diff --git a/codex-rs/core/src/tools/js_repl/kernel.js b/codex-rs/core/src/tools/js_repl/kernel.js deleted file mode 100644 index 3b1972a849..0000000000 --- a/codex-rs/core/src/tools/js_repl/kernel.js +++ /dev/null @@ -1,1833 +0,0 @@ -// Node-based kernel for js_repl. -// Communicates over JSON lines on stdin/stdout. -// Requires Node started with --experimental-vm-modules. - -const { Buffer } = require("node:buffer"); -const { AsyncLocalStorage } = require("node:async_hooks"); -const crypto = require("node:crypto"); -const fs = require("node:fs"); -const { builtinModules, createRequire } = require("node:module"); -const { performance } = require("node:perf_hooks"); -const path = require("node:path"); -const { URL, URLSearchParams, fileURLToPath, pathToFileURL } = require( - "node:url", -); -const { inspect, TextDecoder, TextEncoder } = require("node:util"); -const vm = require("node:vm"); - -const { SourceTextModule, SyntheticModule } = vm; -const meriyahPromise = import("./meriyah.umd.min.js").then( - (m) => m.default ?? m, -); - -// vm contexts start with very few globals. Populate common Node/web globals -// so snippets and dependencies behave like a normal modern JS runtime. -const context = vm.createContext({}); -context.globalThis = context; -context.global = context; -context.Buffer = Buffer; -context.console = console; -context.URL = URL; -context.URLSearchParams = URLSearchParams; -if (typeof TextEncoder !== "undefined") { - context.TextEncoder = TextEncoder; -} -if (typeof TextDecoder !== "undefined") { - context.TextDecoder = TextDecoder; -} -if (typeof AbortController !== "undefined") { - context.AbortController = AbortController; -} -if (typeof AbortSignal !== "undefined") { - context.AbortSignal = AbortSignal; -} -if (typeof structuredClone !== "undefined") { - context.structuredClone = structuredClone; -} -if (typeof fetch !== "undefined") { - context.fetch = fetch; -} -if (typeof Headers !== "undefined") { - context.Headers = Headers; -} -if (typeof Request !== "undefined") { - context.Request = Request; -} -if (typeof Response !== "undefined") { - context.Response = Response; -} -if (typeof performance !== "undefined") { - context.performance = performance; -} -context.crypto = crypto.webcrypto ?? crypto; -context.setTimeout = setTimeout; -context.clearTimeout = clearTimeout; -context.setInterval = setInterval; -context.clearInterval = clearInterval; -context.queueMicrotask = queueMicrotask; -if (typeof setImmediate !== "undefined") { - context.setImmediate = setImmediate; - context.clearImmediate = clearImmediate; -} -context.atob = (data) => Buffer.from(data, "base64").toString("binary"); -context.btoa = (data) => Buffer.from(data, "binary").toString("base64"); - -/** - * @typedef {{ name: string, kind: "const"|"let"|"var"|"function"|"class" }} Binding - */ - -// REPL state model: -// - Every exec is compiled as a fresh ESM "cell". -// - `previousModule` is the most recently committed module namespace. -// - `previousBindings` tracks which top-level names should be carried forward. -// Each new cell imports a synthetic view of the previous namespace and -// redeclares those names so user variables behave like a persistent REPL. -let previousModule = null; -/** @type {Binding[]} */ -let previousBindings = []; -let cellCounter = 0; -let internalBindingCounter = 0; -const internalBindingSalt = (() => { - const raw = process.env.CODEX_THREAD_ID ?? ""; - const sanitized = raw.replace(/[^A-Za-z0-9_$]/g, "_"); - return sanitized || "session"; -})(); -let activeExecId = null; -let fatalExitScheduled = false; - -const builtinModuleSet = new Set([ - ...builtinModules, - ...builtinModules.map((name) => `node:${name}`), -]); -const deniedBuiltinModules = new Set([ - "process", - "node:process", - "child_process", - "node:child_process", - "worker_threads", - "node:worker_threads", -]); - -function toNodeBuiltinSpecifier(specifier) { - return specifier.startsWith("node:") ? specifier : `node:${specifier}`; -} - -function isDeniedBuiltin(specifier) { - const normalized = specifier.startsWith("node:") - ? specifier.slice(5) - : specifier; - return ( - deniedBuiltinModules.has(specifier) || deniedBuiltinModules.has(normalized) - ); -} - -/** @type {Map void>} */ -const pendingTool = new Map(); -/** @type {Map void>} */ -const pendingEmitImage = new Map(); -let toolCounter = 0; -let emitImageCounter = 0; -const execContextStorage = new AsyncLocalStorage(); -const cwd = process.cwd(); -const tmpDir = process.env.CODEX_JS_TMP_DIR || cwd; -const homeDir = process.env.HOME ?? null; -const nodeModuleDirEnv = process.env.CODEX_JS_REPL_NODE_MODULE_DIRS ?? ""; -const moduleSearchBases = (() => { - const bases = []; - const seen = new Set(); - for (const entry of nodeModuleDirEnv.split(path.delimiter)) { - const trimmed = entry.trim(); - if (!trimmed) { - continue; - } - const resolved = path.isAbsolute(trimmed) - ? trimmed - : path.resolve(process.cwd(), trimmed); - const base = - path.basename(resolved) === "node_modules" - ? path.dirname(resolved) - : resolved; - if (seen.has(base)) { - continue; - } - seen.add(base); - bases.push(base); - } - if (!seen.has(cwd)) { - bases.push(cwd); - } - return bases; -})(); - -const importResolveConditions = new Set(["node", "import"]); -const requireByBase = new Map(); -const linkedFileModules = new Map(); -const linkedNativeModules = new Map(); -const linkedModuleEvaluations = new Map(); - -function clearLocalFileModuleCaches() { - linkedFileModules.clear(); - linkedModuleEvaluations.clear(); -} - -function canonicalizePath(value) { - try { - return fs.realpathSync.native(value); - } catch { - return value; - } -} - -function resolveResultToUrl(resolved) { - if (resolved.kind === "builtin") { - return resolved.specifier; - } - if (resolved.kind === "file") { - return pathToFileURL(resolved.path).href; - } - if (resolved.kind === "package") { - return resolved.specifier; - } - throw new Error(`Unsupported module resolution kind: ${resolved.kind}`); -} - -function setImportMeta(meta, mod, isMain = false) { - meta.url = pathToFileURL(mod.identifier).href; - meta.filename = mod.identifier; - meta.dirname = path.dirname(mod.identifier); - meta.main = isMain; - meta.resolve = (specifier) => - resolveResultToUrl(resolveSpecifier(specifier, mod.identifier)); -} - -function getRequireForBase(base) { - let req = requireByBase.get(base); - if (!req) { - req = createRequire(path.join(base, "__codex_js_repl__.cjs")); - requireByBase.set(base, req); - } - return req; -} - -function isModuleNotFoundError(err) { - return ( - err?.code === "MODULE_NOT_FOUND" || err?.code === "ERR_MODULE_NOT_FOUND" - ); -} - -function isWithinBaseNodeModules(base, resolvedPath) { - const canonicalBase = canonicalizePath(base); - const canonicalResolved = canonicalizePath(resolvedPath); - const nodeModulesRoot = path.resolve(canonicalBase, "node_modules"); - const relative = path.relative(nodeModulesRoot, canonicalResolved); - return ( - relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) - ); -} - -function isExplicitRelativePathSpecifier(specifier) { - return ( - specifier.startsWith("./") || - specifier.startsWith("../") || - specifier.startsWith(".\\") || - specifier.startsWith("..\\") - ); -} - -function isFileUrlSpecifier(specifier) { - if (typeof specifier !== "string" || !specifier.startsWith("file:")) { - return false; - } - try { - return new URL(specifier).protocol === "file:"; - } catch { - return false; - } -} - -function isPathSpecifier(specifier) { - if ( - typeof specifier !== "string" || - !specifier || - specifier.trim() !== specifier - ) { - return false; - } - return ( - isExplicitRelativePathSpecifier(specifier) || - path.isAbsolute(specifier) || - isFileUrlSpecifier(specifier) - ); -} - -function isBarePackageSpecifier(specifier) { - if ( - typeof specifier !== "string" || - !specifier || - specifier.trim() !== specifier - ) { - return false; - } - if (specifier.startsWith("./") || specifier.startsWith("../")) { - return false; - } - if (specifier.startsWith("/") || specifier.startsWith("\\")) { - return false; - } - if (path.isAbsolute(specifier)) { - return false; - } - if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(specifier)) { - return false; - } - if (specifier.includes("\\")) { - return false; - } - return true; -} - -function resolveBareSpecifier(specifier) { - let firstResolutionError = null; - - for (const base of moduleSearchBases) { - try { - const resolved = getRequireForBase(base).resolve(specifier, { - conditions: importResolveConditions, - }); - if (isWithinBaseNodeModules(base, resolved)) { - return resolved; - } - // Ignore resolutions that escape this base via parent node_modules lookup. - } catch (err) { - if (isModuleNotFoundError(err)) { - continue; - } - if (!firstResolutionError) { - firstResolutionError = err; - } - } - } - - if (firstResolutionError) { - throw firstResolutionError; - } - return null; -} - -function resolvePathSpecifier(specifier, referrerIdentifier = null) { - let candidate; - if (isFileUrlSpecifier(specifier)) { - try { - candidate = fileURLToPath(new URL(specifier)); - } catch (err) { - throw new Error(`Failed to resolve module "${specifier}": ${err.message}`); - } - } else { - const baseDir = - referrerIdentifier && path.isAbsolute(referrerIdentifier) - ? path.dirname(referrerIdentifier) - : process.cwd(); - candidate = path.isAbsolute(specifier) - ? specifier - : path.resolve(baseDir, specifier); - } - - let resolvedPath; - try { - resolvedPath = fs.realpathSync.native(candidate); - } catch (err) { - if (err?.code === "ENOENT") { - throw new Error(`Module not found: ${specifier}`); - } - throw new Error(`Failed to resolve module "${specifier}": ${err.message}`); - } - - let stats; - try { - stats = fs.statSync(resolvedPath); - } catch (err) { - if (err?.code === "ENOENT") { - throw new Error(`Module not found: ${specifier}`); - } - throw new Error(`Failed to inspect module "${specifier}": ${err.message}`); - } - - if (!stats.isFile()) { - throw new Error( - `Unsupported import specifier "${specifier}" in js_repl. Directory imports are not supported.`, - ); - } - - const extension = path.extname(resolvedPath).toLowerCase(); - if (extension !== ".js" && extension !== ".mjs") { - throw new Error( - `Unsupported import specifier "${specifier}" in js_repl. Only .js and .mjs files are supported.`, - ); - } - - return { kind: "file", path: resolvedPath }; -} - -function resolveSpecifier(specifier, referrerIdentifier = null) { - if (specifier.startsWith("node:") || builtinModuleSet.has(specifier)) { - if (isDeniedBuiltin(specifier)) { - throw new Error( - `Importing module "${specifier}" is not allowed in js_repl`, - ); - } - return { kind: "builtin", specifier: toNodeBuiltinSpecifier(specifier) }; - } - - if (isPathSpecifier(specifier)) { - return resolvePathSpecifier(specifier, referrerIdentifier); - } - - if (!isBarePackageSpecifier(specifier)) { - throw new Error( - `Unsupported import specifier "${specifier}" in js_repl. Use a package name like "lodash" or "@scope/pkg", or a relative/absolute/file:// .js/.mjs path.`, - ); - } - - const resolvedBare = resolveBareSpecifier(specifier); - if (!resolvedBare) { - throw new Error(`Module not found: ${specifier}`); - } - - return { kind: "package", path: resolvedBare, specifier }; -} - -function importNativeResolved(resolved) { - if (resolved.kind === "builtin") { - return import(resolved.specifier); - } - if (resolved.kind === "package") { - return import(pathToFileURL(resolved.path).href); - } - throw new Error(`Unsupported module resolution kind: ${resolved.kind}`); -} - -async function loadLinkedNativeModule(resolved) { - const key = - resolved.kind === "builtin" - ? `builtin:${resolved.specifier}` - : `package:${resolved.path}`; - let modulePromise = linkedNativeModules.get(key); - if (!modulePromise) { - modulePromise = (async () => { - const namespace = await importNativeResolved(resolved); - const exportNames = Object.getOwnPropertyNames(namespace); - return new SyntheticModule( - exportNames, - function initSyntheticModule() { - for (const name of exportNames) { - this.setExport(name, namespace[name]); - } - }, - { context }, - ); - })(); - linkedNativeModules.set(key, modulePromise); - } - return modulePromise; -} - -async function loadLinkedFileModule(modulePath) { - let module = linkedFileModules.get(modulePath); - if (!module) { - const source = fs.readFileSync(modulePath, "utf8"); - module = new SourceTextModule(source, { - context, - identifier: modulePath, - initializeImportMeta(meta, mod) { - setImportMeta(meta, mod, false); - }, - importModuleDynamically(specifier, referrer) { - return importResolved(resolveSpecifier(specifier, referrer?.identifier)); - }, - }); - linkedFileModules.set(modulePath, module); - } - if (module.status === "unlinked") { - await module.link(async (specifier, referencingModule) => { - const resolved = resolveSpecifier(specifier, referencingModule?.identifier); - if (resolved.kind !== "file") { - throw new Error( - `Static import "${specifier}" is not supported from js_repl local files. Use await import("${specifier}") instead.`, - ); - } - return loadLinkedFileModule(resolved.path); - }); - } - return module; -} - -async function loadLinkedModule(resolved) { - if (resolved.kind === "file") { - return loadLinkedFileModule(resolved.path); - } - if (resolved.kind === "builtin" || resolved.kind === "package") { - return loadLinkedNativeModule(resolved); - } - throw new Error(`Unsupported module resolution kind: ${resolved.kind}`); -} - -async function importResolved(resolved) { - if (resolved.kind === "file") { - const module = await loadLinkedFileModule(resolved.path); - let evaluation = linkedModuleEvaluations.get(resolved.path); - if (!evaluation) { - evaluation = module.evaluate(); - linkedModuleEvaluations.set(resolved.path, evaluation); - } - await evaluation; - return module.namespace; - } - return importNativeResolved(resolved); -} - -function collectPatternNames(pattern, kind, map) { - if (!pattern) return; - switch (pattern.type) { - case "Identifier": - if (!map.has(pattern.name)) map.set(pattern.name, kind); - return; - case "ObjectPattern": - for (const prop of pattern.properties ?? []) { - if (prop.type === "Property") { - collectPatternNames(prop.value, kind, map); - } else if (prop.type === "RestElement") { - collectPatternNames(prop.argument, kind, map); - } - } - return; - case "ArrayPattern": - for (const elem of pattern.elements ?? []) { - if (!elem) continue; - if (elem.type === "RestElement") { - collectPatternNames(elem.argument, kind, map); - } else { - collectPatternNames(elem, kind, map); - } - } - return; - case "AssignmentPattern": - collectPatternNames(pattern.left, kind, map); - return; - case "RestElement": - collectPatternNames(pattern.argument, kind, map); - return; - default: - return; - } -} - -function collectBindings(ast) { - const map = new Map(); - for (const stmt of ast.body ?? []) { - if (stmt.type === "VariableDeclaration") { - const kind = stmt.kind; - for (const decl of stmt.declarations) { - collectPatternNames(decl.id, kind, map); - } - } else if (stmt.type === "FunctionDeclaration" && stmt.id) { - map.set(stmt.id.name, "function"); - } else if (stmt.type === "ClassDeclaration" && stmt.id) { - map.set(stmt.id.name, "class"); - } else if (stmt.type === "ForStatement") { - if ( - stmt.init && - stmt.init.type === "VariableDeclaration" && - stmt.init.kind === "var" - ) { - for (const decl of stmt.init.declarations) { - collectPatternNames(decl.id, "var", map); - } - } - } else if ( - stmt.type === "ForInStatement" || - stmt.type === "ForOfStatement" - ) { - if ( - stmt.left && - stmt.left.type === "VariableDeclaration" && - stmt.left.kind === "var" - ) { - for (const decl of stmt.left.declarations) { - collectPatternNames(decl.id, "var", map); - } - } - } - } - return Array.from(map.entries()).map(([name, kind]) => ({ name, kind })); -} - -function collectPatternBindingNames(pattern) { - const map = new Map(); - collectPatternNames(pattern, "binding", map); - return Array.from(map.keys()); -} - -function nextInternalBindingName() { - // We intentionally do not scan user-declared names here. Internal helpers use - // a per-thread salt plus a counter instead. A user could still collide by - // deliberately spelling the exact generated name, but the thread-id salt - // keeps accidental collisions negligible while avoiding more AST bookkeeping. - return `__codex_internal_commit_${internalBindingSalt}_${internalBindingCounter++}`; -} - -function buildMarkCommittedExpression(names, markCommittedFnName) { - const serializedNames = names.map((name) => JSON.stringify(name)).join(", "); - return `(${markCommittedFnName}(${serializedNames}), undefined)`; -} - -function tryReadBindingValue(module, bindingName) { - if (!module) { - return { ok: false, value: undefined }; - } - - try { - return { ok: true, value: module.namespace[bindingName] }; - } catch { - return { ok: false, value: undefined }; - } -} - -function instrumentVariableDeclarationSource( - code, - declaration, - markCommittedFnName, -) { - if (!declaration.declarations?.length) { - return code.slice(declaration.start, declaration.end); - } - - const prefix = code.slice(declaration.start, declaration.declarations[0].start); - const suffix = code.slice( - declaration.declarations[declaration.declarations.length - 1].end, - declaration.end, - ); - const parts = []; - - for (const decl of declaration.declarations) { - parts.push(code.slice(decl.start, decl.end)); - - const names = collectPatternBindingNames(decl.id); - if (names.length > 0) { - const helperName = nextInternalBindingName(); - parts.push( - `${helperName} = ${buildMarkCommittedExpression(names, markCommittedFnName)}`, - ); - } - } - - return `${prefix}${parts.join(", ")}${suffix}`; -} - -function instrumentLoopBody(code, body, names, guardName, markCommittedFnName) { - const marker = `if (${guardName}) { ${guardName} = false; ${markCommittedFnName}(${names - .map((name) => JSON.stringify(name)) - .join(", ")}); }`; - const bodyCode = code.slice(body.start, body.end); - - if (body.type === "BlockStatement") { - return `{ ${marker}${bodyCode.slice(1)}`; - } - - return `{ ${marker} ${bodyCode} }`; -} - -function applyReplacements(code, replacements) { - let instrumentedCode = code; - - for (const replacement of replacements.sort((a, b) => b.start - a.start)) { - instrumentedCode = - instrumentedCode.slice(0, replacement.start) + - replacement.text + - instrumentedCode.slice(replacement.end); - } - - return instrumentedCode; -} - -function collectHoistedVarDeclarationStarts(ast) { - const varDeclarationStarts = new Map(); - - const recordDeclarationStart = (map, name, start) => { - const existingStart = map.get(name); - if (existingStart === undefined || start < existingStart) { - map.set(name, start); - } - }; - - const recordVarDeclarationStarts = (declaration) => { - for (const name of collectPatternBindingNames(declaration.id)) { - recordDeclarationStart(varDeclarationStarts, name, declaration.start); - } - }; - - for (const stmt of ast.body ?? []) { - if (stmt.type === "VariableDeclaration" && stmt.kind === "var") { - for (const declaration of stmt.declarations ?? []) { - recordVarDeclarationStarts(declaration); - } - continue; - } - - if ( - stmt.type === "ForStatement" && - stmt.init?.type === "VariableDeclaration" && - stmt.init.kind === "var" - ) { - for (const declaration of stmt.init.declarations ?? []) { - recordVarDeclarationStarts(declaration); - } - continue; - } - - if ( - (stmt.type === "ForInStatement" || stmt.type === "ForOfStatement") && - stmt.left?.type === "VariableDeclaration" && - stmt.left.kind === "var" - ) { - for (const declaration of stmt.left.declarations ?? []) { - recordVarDeclarationStarts(declaration); - } - } - } - - return varDeclarationStarts; -} - -function collectFutureVarWriteReplacements( - code, - ast, - { - helperDeclarations = null, - markCommittedFnName = null, - } = {}, -) { - // Failed-cell hoisted tracking intentionally stays small here. We only mark - // direct top-level writes to future `var` bindings, plus top-level - // declaration-site markers handled later in `instrumentCurrentBindings`. - // We do not recurse through nested statement structure because that quickly - // requires real lexical-scope tracking for blocks, loop scopes, catch - // bindings, and similar shadowing cases. Supported write recovery is limited - // to direct top-level expression statements such as `x = 1`, `x += 1`, - // `x++`, and logical assignments. - const varDeclarationStarts = collectHoistedVarDeclarationStarts(ast); - if (varDeclarationStarts.size === 0) { - return []; - } - const replacements = []; - const replacementKeys = new Set(); - - if (!markCommittedFnName) { - throw new Error( - "collectFutureVarWriteReplacements expected a commit marker binding name", - ); - } - - const addReplacement = (start, end, text) => { - const key = `${start}:${end}`; - if (!replacementKeys.has(key)) { - replacementKeys.add(key); - replacements.push({ start, end, text }); - } - }; - - const getFutureVarName = (identifier) => { - if (!identifier || identifier.type !== "Identifier") { - return null; - } - - const declarationStart = varDeclarationStarts.get(identifier.name); - if ( - declarationStart === undefined || - identifier.start >= declarationStart - ) { - return null; - } - - return identifier.name; - }; - - const instrumentUpdateExpression = (node, identifier) => { - const bindingName = getFutureVarName(identifier); - if (!bindingName) { - return false; - } - - addReplacement( - node.start, - node.end, - `(${markCommittedFnName}(${JSON.stringify(bindingName)}), ${code.slice( - node.start, - node.end, - )})`, - ); - return true; - }; - - const instrumentAssignmentExpression = (node) => { - if (node.left.type !== "Identifier") { - return false; - } - - const bindingName = getFutureVarName(node.left); - if (!bindingName) { - return false; - } - - if ( - node.operator === "&&=" || - node.operator === "||=" || - node.operator === "??=" - ) { - if (!helperDeclarations) { - throw new Error( - "collectFutureVarWriteReplacements expected helperDeclarations for logical assignment rewriting", - ); - } - - const helperName = nextInternalBindingName(); - helperDeclarations.push(`let ${helperName};`); - const shortCircuitOperator = - node.operator === "&&=" - ? "&&" - : node.operator === "||=" - ? "||" - : "??"; - addReplacement( - node.start, - node.end, - `((${helperName} = ${node.left.name}), ${helperName} ${shortCircuitOperator} ((${node.left.name} = ${code.slice(node.right.start, node.right.end)}), ${buildMarkCommittedExpression([bindingName], markCommittedFnName)}, ${node.left.name}))`, - ); - return true; - } - - addReplacement( - node.start, - node.end, - `((${code.slice(node.start, node.end)}), ${buildMarkCommittedExpression([bindingName], markCommittedFnName)}, ${node.left.name})`, - ); - return true; - }; - - const unwrapParenthesizedExpression = (node) => { - let current = node; - while (current?.type === "ParenthesizedExpression") { - current = current.expression; - } - return current; - }; - - for (const statement of ast.body ?? []) { - if (statement.type !== "ExpressionStatement") { - continue; - } - - const expression = unwrapParenthesizedExpression(statement.expression); - if (!expression) { - continue; - } - - if ( - expression.type === "UpdateExpression" && - expression.argument.type === "Identifier" - ) { - instrumentUpdateExpression(expression, expression.argument); - continue; - } - - if (expression.type === "AssignmentExpression") { - instrumentAssignmentExpression(expression); - } - } - - return replacements; -} - -function instrumentCurrentBindings( - code, - ast, - currentBindings, - priorBindings, - markCommittedFnName, -) { - if (currentBindings.length === 0) { - return code; - } - - const replacements = []; - - for (const stmt of ast.body ?? []) { - if (stmt.type === "VariableDeclaration") { - replacements.push({ - start: stmt.start, - end: stmt.end, - text: instrumentVariableDeclarationSource( - code, - stmt, - markCommittedFnName, - ), - }); - continue; - } - - if (stmt.type === "FunctionDeclaration" && stmt.id) { - replacements.push({ - start: stmt.start, - end: stmt.end, - // Keep function source text stable for things like `foo.toString()`. - // Pre-declaration uses are tracked separately by instrumenting the - // top-level expressions that actually read the hoisted function value. - text: `${code.slice(stmt.start, stmt.end)}\n;${markCommittedFnName}(${JSON.stringify(stmt.id.name)});`, - }); - continue; - } - - if (stmt.type === "ClassDeclaration" && stmt.id) { - replacements.push({ - start: stmt.start, - end: stmt.end, - text: `${code.slice(stmt.start, stmt.end)}\n;${markCommittedFnName}(${JSON.stringify(stmt.id.name)});`, - }); - continue; - } - - if ( - stmt.type === "ForStatement" && - stmt.init && - stmt.init.type === "VariableDeclaration" && - stmt.init.kind === "var" - ) { - replacements.push({ - start: stmt.start, - end: stmt.end, - text: `${code.slice(stmt.start, stmt.init.start)}${instrumentVariableDeclarationSource( - code, - stmt.init, - markCommittedFnName, - )}${code.slice(stmt.init.end, stmt.end)}`, - }); - continue; - } - - if ( - (stmt.type === "ForInStatement" || stmt.type === "ForOfStatement") && - stmt.left && - stmt.left.type === "VariableDeclaration" && - stmt.left.kind === "var" - ) { - const names = stmt.left.declarations.flatMap((decl) => - collectPatternBindingNames(decl.id), - ); - if (names.length > 0) { - const guardName = nextInternalBindingName(); - replacements.push({ - start: stmt.start, - end: stmt.end, - // Mark top-level `for...in` / `for...of` vars on the first body - // execution instead of every iteration. This keeps hot loops cheap - // after the first pass while still preserving vars for the common - // case where the loop actually ran before a later throw. - // - // The tradeoff is that `for (var x of []) {}` in a failed cell will - // not carry `x` forward as `undefined`, because the body never runs - // and the one-time marker never fires. We accept that edge case: - // `var` is redeclarable, and the only lost state is an unassigned - // `undefined` from an empty top-level loop in a cell that later - // fails. - text: `let ${guardName} = true;\n${code.slice( - stmt.start, - stmt.body.start, - )}${instrumentLoopBody( - code, - stmt.body, - names, - guardName, - markCommittedFnName, - )}`, - }); - } - } - } - - return applyReplacements(code, replacements); -} - -async function buildModuleSource(code) { - const meriyah = await meriyahPromise; - const ast = meriyah.parseModule(code, { - next: true, - module: true, - ranges: true, - loc: false, - disableWebCompat: true, - }); - const currentBindings = collectBindings(ast); - const priorBindings = previousModule ? previousBindings : []; - const helperDeclarations = []; - const markCommittedFnName = nextInternalBindingName(); - const markPreludeCompletedFnName = nextInternalBindingName(); - helperDeclarations.push( - // `import.meta` is syntax-level and cannot be shadowed by user bindings - // like `const globalThis = ...`, so alias the marker helper through it - // once in the prelude and use that stable local binding everywhere. - // Then delete the raw import.meta hooks so user code cannot spoof - // committed bindings by calling them directly. - `const ${markCommittedFnName} = import.meta.__codexInternalMarkCommittedBindings;`, - `const ${markPreludeCompletedFnName} = import.meta.__codexInternalMarkPreludeCompleted;`, - "delete import.meta.__codexInternalMarkCommittedBindings;", - "delete import.meta.__codexInternalMarkPreludeCompleted;", - ); - const writeInstrumentedCode = applyReplacements( - code, - collectFutureVarWriteReplacements(code, ast, { - helperDeclarations, - markCommittedFnName, - }), - ); - const instrumentedAst = meriyah.parseModule(writeInstrumentedCode, { - next: true, - module: true, - ranges: true, - loc: false, - disableWebCompat: true, - }); - const instrumentedCode = instrumentCurrentBindings( - writeInstrumentedCode, - instrumentedAst, - currentBindings, - priorBindings, - markCommittedFnName, - ); - - let prelude = ""; - if (previousModule && priorBindings.length) { - // Recreate carried bindings before running user code in this new cell. - prelude += 'import * as __prev from "@prev";\n'; - prelude += priorBindings - .map((b) => { - const keyword = - b.kind === "var" ? "var" : b.kind === "const" ? "const" : "let"; - return `${keyword} ${b.name} = __prev.${b.name};`; - }) - .join("\n"); - prelude += "\n"; - } - if (helperDeclarations.length > 0) { - prelude += `${helperDeclarations.join("\n")}\n`; - } - prelude += `${markPreludeCompletedFnName}();\n`; - - const mergedBindings = new Map(); - for (const binding of priorBindings) { - mergedBindings.set(binding.name, binding.kind); - } - for (const binding of currentBindings) { - mergedBindings.set(binding.name, binding.kind); - } - // Export the merged binding set so the next cell can import it through @prev. - const exportNames = Array.from(mergedBindings.keys()); - const exportStmt = exportNames.length - ? `\nexport { ${exportNames.join(", ")} };` - : ""; - - const nextBindings = Array.from(mergedBindings, ([name, kind]) => ({ - name, - kind, - })); - return { - source: `${prelude}${instrumentedCode}${exportStmt}`, - currentBindings, - nextBindings, - priorBindings, - }; -} - -function canReadCommittedBinding(module, binding) { - if ( - !module || - binding.kind === "var" || - binding.kind === "function" - ) { - return false; - } - - return tryReadBindingValue(module, binding.name).ok; -} -// Failed cells keep prior bindings plus the current-cell bindings whose -// initialization definitely ran before the throw. That means: -// - lexical bindings (`const` / `let` / `class`) can fall back to namespace -// readability, which preserves names whose initialization already completed -// even when a later step in the same declarator throws -// - `var` / `function` bindings only persist when an explicit declaration-site -// or write-site marker fired, so unreached hoisted bindings do not become -// ghost bindings in later cells -function collectCommittedBindings( - module, - priorBindings, - currentBindings, - committedCurrentBindingNames, -) { - const mergedBindings = new Map(); - let committedCurrentBindingCount = 0; - - for (const binding of priorBindings) { - mergedBindings.set(binding.name, binding.kind); - } - - for (const binding of currentBindings) { - if ( - committedCurrentBindingNames.has(binding.name) || - canReadCommittedBinding(module, binding) - ) { - mergedBindings.set(binding.name, binding.kind); - committedCurrentBindingCount += 1; - } - } - - return { - bindings: Array.from(mergedBindings, ([name, kind]) => ({ name, kind })), - committedCurrentBindingCount, - }; -} - -function send(message) { - process.stdout.write(JSON.stringify(message)); - process.stdout.write("\n"); -} - -function formatErrorMessage(error) { - if (error && typeof error === "object" && "message" in error) { - return error.message ? String(error.message) : String(error); - } - return String(error); -} - -function sendFatalExecResultSync(kind, error) { - if (!activeExecId) { - return; - } - const payload = { - type: "exec_result", - id: activeExecId, - ok: false, - output: "", - error: `js_repl kernel ${kind}: ${formatErrorMessage(error)}; kernel reset. Catch or handle async errors (including Promise rejections and EventEmitter 'error' events) to avoid kernel termination.`, - }; - try { - fs.writeSync(process.stdout.fd, `${JSON.stringify(payload)}\n`); - } catch { - // Best effort only; the host will still surface stdout EOF diagnostics. - } -} - -function getCurrentExecState() { - const execState = execContextStorage.getStore(); - if (!execState || typeof execState.id !== "string" || !execState.id) { - throw new Error("js_repl exec context not found"); - } - return execState; -} - -function scheduleFatalExit(kind, error) { - if (fatalExitScheduled) { - process.exitCode = 1; - return; - } - fatalExitScheduled = true; - sendFatalExecResultSync(kind, error); - - try { - fs.writeSync( - process.stderr.fd, - `js_repl kernel ${kind}: ${formatErrorMessage(error)}\n`, - ); - } catch { - // ignore - } - - // The host will observe stdout EOF, reset kernel state, and restart on demand. - setImmediate(() => { - process.exit(1); - }); -} - -function formatLog(args) { - return args - .map((arg) => - typeof arg === "string" ? arg : inspect(arg, { depth: 4, colors: false }), - ) - .join(" "); -} - -function withCapturedConsole(ctx, fn) { - const logs = []; - const original = ctx.console ?? console; - const captured = { - ...original, - log: (...args) => { - logs.push(formatLog(args)); - }, - info: (...args) => { - logs.push(formatLog(args)); - }, - warn: (...args) => { - logs.push(formatLog(args)); - }, - error: (...args) => { - logs.push(formatLog(args)); - }, - debug: (...args) => { - logs.push(formatLog(args)); - }, - }; - ctx.console = captured; - return fn(logs).finally(() => { - ctx.console = original; - }); -} - -function isPlainObject(value) { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function toByteArray(value) { - if (value instanceof Uint8Array) { - return value; - } - if (value instanceof ArrayBuffer) { - return new Uint8Array(value); - } - if (ArrayBuffer.isView(value)) { - return new Uint8Array(value.buffer, value.byteOffset, value.byteLength); - } - return null; -} - -function encodeByteImage(bytes, mimeType, detail) { - if (bytes.byteLength === 0) { - throw new Error("codex.emitImage expected non-empty bytes"); - } - if (typeof mimeType !== "string" || !mimeType) { - throw new Error("codex.emitImage expected a non-empty mimeType"); - } - assertEmitImageMimeType(mimeType); - const image_url = `data:${mimeType};base64,${Buffer.from(bytes).toString("base64")}`; - return { image_url, detail }; -} - -function parseImageDetail(detail) { - if (detail == null) { - return undefined; - } - if (typeof detail !== "string" || !detail) { - throw new Error("codex.emitImage expected detail to be a non-empty string"); - } - if (!["auto", "low", "high", "original"].includes(detail)) { - throw new Error( - 'codex.emitImage expected detail to be one of "auto", "low", "high", or "original"', - ); - } - return detail; -} - -function normalizeEmitImageUrl(value) { - if (typeof value !== "string" || !value) { - throw new Error("codex.emitImage expected a non-empty image_url"); - } - if (!/^data:/i.test(value)) { - throw new Error("codex.emitImage only accepts data URLs"); - } - const mimeType = parseDataUrlMimeType(value); - assertEmitImageMimeType(mimeType); - return value; -} - -const SUPPORTED_EMIT_IMAGE_MIME_TYPES = [ - "image/png", - "image/jpeg", - "image/webp", - "image/gif", -]; - -function parseDataUrlMimeType(dataUrl) { - const commaIndex = dataUrl.indexOf(","); - if (commaIndex < 0) { - throw new Error("codex.emitImage expected a valid image data URL"); - } - const mediaType = dataUrl.slice("data:".length, commaIndex).split(";")[0]; - if (!mediaType) { - throw new Error("codex.emitImage expected image data URL to include a MIME type"); - } - return mediaType; -} - -function assertEmitImageMimeType(mimeType) { - const normalized = typeof mimeType === "string" ? mimeType.toLowerCase() : ""; - if (!SUPPORTED_EMIT_IMAGE_MIME_TYPES.includes(normalized)) { - const supportedTypes = `${SUPPORTED_EMIT_IMAGE_MIME_TYPES.slice(0, -1).join(", ")}, or ${ - SUPPORTED_EMIT_IMAGE_MIME_TYPES[SUPPORTED_EMIT_IMAGE_MIME_TYPES.length - 1] - }`; - throw new Error( - `codex.emitImage only supports ${supportedTypes}`, - ); - } -} - -function parseInputImageItem(value) { - if (!isPlainObject(value) || value.type !== "input_image") { - return null; - } - return { - images: [ - { - image_url: normalizeEmitImageUrl(value.image_url), - detail: parseImageDetail(value.detail), - }, - ], - textCount: 0, - }; -} - -function parseContentItems(items) { - if (!Array.isArray(items)) { - return null; - } - - const images = []; - let textCount = 0; - for (const item of items) { - if (!isPlainObject(item) || typeof item.type !== "string") { - throw new Error("codex.emitImage received malformed content items"); - } - if (item.type === "input_image") { - images.push({ - image_url: normalizeEmitImageUrl(item.image_url), - detail: parseImageDetail(item.detail), - }); - continue; - } - if (item.type === "input_text" || item.type === "output_text") { - textCount += 1; - continue; - } - throw new Error( - `codex.emitImage does not support content item type "${item.type}"`, - ); - } - - return { images, textCount }; -} - -function parseByteImageValue(value) { - if (!isPlainObject(value) || !("bytes" in value)) { - return null; - } - const bytes = toByteArray(value.bytes); - if (!bytes) { - throw new Error( - "codex.emitImage expected bytes to be Buffer, Uint8Array, ArrayBuffer, or ArrayBufferView", - ); - } - const detail = parseImageDetail(value.detail); - return encodeByteImage(bytes, value.mimeType, detail); -} - -function parseToolOutput(output) { - if (typeof output === "string") { - return { - images: [], - textCount: output.length > 0 ? 1 : 0, - }; - } - - const parsedItems = parseContentItems(output); - if (parsedItems) { - return parsedItems; - } - - throw new Error("codex.emitImage received an unsupported tool output shape"); -} - -function normalizeMcpImageData(data, mimeType) { - if (typeof data !== "string" || !data) { - throw new Error("codex.emitImage expected MCP image data"); - } - if (/^data:/i.test(data)) { - return data; - } - const normalizedMimeType = - typeof mimeType === "string" && mimeType ? mimeType : "application/octet-stream"; - return `data:${normalizedMimeType};base64,${data}`; -} - -function parseMcpImageDetail(meta) { - if (!isPlainObject(meta)) { - return undefined; - } - const detail = meta["codex/imageDetail"]; - if ( - typeof detail !== "string" || - !["auto", "low", "high", "original"].includes(detail) - ) { - return undefined; - } - return detail; -} - -function parseMcpToolResult(result) { - if (typeof result === "string") { - return { images: [], textCount: result.length > 0 ? 1 : 0 }; - } - - if (!isPlainObject(result)) { - throw new Error("codex.emitImage received an unsupported MCP result"); - } - - if ("Err" in result) { - const error = result.Err; - return { images: [], textCount: typeof error === "string" && error ? 1 : 0 }; - } - - if (!("Ok" in result)) { - throw new Error("codex.emitImage received an unsupported MCP result"); - } - - const ok = result.Ok; - if (!isPlainObject(ok) || !Array.isArray(ok.content)) { - throw new Error("codex.emitImage received malformed MCP content"); - } - - const images = []; - let textCount = 0; - for (const item of ok.content) { - if (!isPlainObject(item) || typeof item.type !== "string") { - throw new Error("codex.emitImage received malformed MCP content"); - } - if (item.type === "image") { - images.push({ - image_url: normalizeMcpImageData(item.data, item.mimeType ?? item.mime_type), - detail: parseMcpImageDetail(item._meta), - }); - continue; - } - if (item.type === "text") { - textCount += 1; - continue; - } - throw new Error( - `codex.emitImage does not support MCP content type "${item.type}"`, - ); - } - - return { images, textCount }; -} - -function requireSingleImage(parsed) { - if (parsed.textCount > 0) { - throw new Error("codex.emitImage does not accept mixed text and image content"); - } - if (parsed.images.length !== 1) { - throw new Error("codex.emitImage expected exactly one image"); - } - return parsed.images[0]; -} - -function normalizeEmitImageValue(value) { - if (typeof value === "string") { - return { image_url: normalizeEmitImageUrl(value) }; - } - - const directItem = parseInputImageItem(value); - if (directItem) { - return requireSingleImage(directItem); - } - - const byteImage = parseByteImageValue(value); - if (byteImage) { - return byteImage; - } - - const directItems = parseContentItems(value); - if (directItems) { - return requireSingleImage(directItems); - } - - if (!isPlainObject(value)) { - throw new Error("codex.emitImage received an unsupported value"); - } - - if (value.type === "message") { - return requireSingleImage(parseContentItems(value.content)); - } - - if ( - value.type === "function_call_output" || - value.type === "custom_tool_call_output" - ) { - return requireSingleImage(parseToolOutput(value.output)); - } - - if (value.type === "mcp_tool_call_output") { - return requireSingleImage(parseMcpToolResult(value.result)); - } - - if ("output" in value) { - return requireSingleImage(parseToolOutput(value.output)); - } - - if ("content" in value) { - return requireSingleImage(parseContentItems(value.content)); - } - - throw new Error("codex.emitImage received an unsupported value"); -} - -const codex = { - cwd, - homeDir, - tmpDir, - tool(toolName, args) { - let execState; - try { - execState = getCurrentExecState(); - } catch (error) { - return Promise.reject(error); - } - if (typeof toolName !== "string" || !toolName) { - return Promise.reject(new Error("codex.tool expects a tool name string")); - } - const id = `${execState.id}-tool-${toolCounter++}`; - let argumentsJson = "{}"; - if (typeof args === "string") { - argumentsJson = args; - } else if (typeof args !== "undefined") { - argumentsJson = JSON.stringify(args); - } - - return new Promise((resolve, reject) => { - const payload = { - type: "run_tool", - id, - exec_id: execState.id, - tool_name: toolName, - arguments: argumentsJson, - }; - send(payload); - pendingTool.set(id, (res) => { - if (!res.ok) { - reject(new Error(res.error || "tool failed")); - return; - } - resolve(res.response); - }); - }); - }, - emitImage(imageLike) { - let execState; - try { - execState = getCurrentExecState(); - } catch (error) { - return { - then(onFulfilled, onRejected) { - return Promise.reject(error).then(onFulfilled, onRejected); - }, - catch(onRejected) { - return Promise.reject(error).catch(onRejected); - }, - finally(onFinally) { - return Promise.reject(error).finally(onFinally); - }, - }; - } - const operation = (async () => { - const normalized = normalizeEmitImageValue(await imageLike); - const id = `${execState.id}-emit-image-${emitImageCounter++}`; - const payload = { - type: "emit_image", - id, - exec_id: execState.id, - image_url: normalized.image_url, - detail: normalized.detail ?? null, - }; - send(payload); - return new Promise((resolve, reject) => { - pendingEmitImage.set(id, (res) => { - if (!res.ok) { - reject(new Error(res.error || "emitImage failed")); - return; - } - resolve(); - }); - }); - })(); - - const observation = { observed: false }; - const trackedOperation = operation.then( - () => ({ ok: true, error: null, observation }), - (error) => ({ ok: false, error, observation }), - ); - execState.pendingBackgroundTasks.add(trackedOperation); - return { - then(onFulfilled, onRejected) { - observation.observed = true; - return operation.then(onFulfilled, onRejected); - }, - catch(onRejected) { - observation.observed = true; - return operation.catch(onRejected); - }, - finally(onFinally) { - observation.observed = true; - return operation.finally(onFinally); - }, - }; - }, -}; - -async function handleExec(message) { - clearLocalFileModuleCaches(); - activeExecId = message.id; - const execState = { - id: message.id, - pendingBackgroundTasks: new Set(), - }; - - let module = null; - /** @type {Binding[]} */ - let currentBindings = []; - /** @type {Binding[]} */ - let nextBindings = []; - /** @type {Binding[]} */ - let priorBindings = previousBindings; - let moduleLinked = false; - let preludeCompleted = false; - const committedCurrentBindingNames = new Set(); - const markCommittedBindings = (...names) => { - for (const name of names) { - committedCurrentBindingNames.add(name); - } - }; - const markPreludeCompleted = () => { - preludeCompleted = true; - }; - - try { - const code = typeof message.code === "string" ? message.code : ""; - const builtSource = await buildModuleSource(code); - const source = builtSource.source; - currentBindings = builtSource.currentBindings; - nextBindings = builtSource.nextBindings; - priorBindings = builtSource.priorBindings; - let output = ""; - - context.codex = codex; - context.tmpDir = tmpDir; - - await execContextStorage.run(execState, async () => { - await withCapturedConsole(context, async (logs) => { - const cellIdentifier = path.join( - cwd, - `.codex_js_repl_cell_${cellCounter++}.mjs`, - ); - module = new SourceTextModule(source, { - context, - identifier: cellIdentifier, - initializeImportMeta(meta, mod) { - setImportMeta(meta, mod, true); - meta.__codexInternalMarkCommittedBindings = markCommittedBindings; - meta.__codexInternalMarkPreludeCompleted = markPreludeCompleted; - }, - importModuleDynamically(specifier, referrer) { - return importResolved(resolveSpecifier(specifier, referrer?.identifier)); - }, - }); - - await module.link(async (specifier) => { - if (specifier === "@prev" && previousModule) { - const exportNames = previousBindings.map((b) => b.name); - // Build a synthetic module snapshot of the prior cell's exports. - // This is the bridge that carries values from cell N to cell N+1. - const synthetic = new SyntheticModule( - exportNames, - function initSynthetic() { - for (const binding of previousBindings) { - this.setExport( - binding.name, - previousModule.namespace[binding.name], - ); - } - }, - { context }, - ); - return synthetic; - } - throw new Error( - `Top-level static import "${specifier}" is not supported in js_repl. Use await import("${specifier}") instead.`, - ); - }); - moduleLinked = true; - - await module.evaluate(); - if (execState.pendingBackgroundTasks.size > 0) { - const backgroundResults = await Promise.all([ - ...execState.pendingBackgroundTasks, - ]); - const firstUnhandledBackgroundError = backgroundResults.find( - (result) => !result.ok && !result.observation.observed, - ); - if (firstUnhandledBackgroundError) { - throw firstUnhandledBackgroundError.error; - } - } - output = logs.join("\n"); - }); - }); - - previousModule = module; - previousBindings = nextBindings; - - send({ - type: "exec_result", - id: message.id, - ok: true, - output, - error: null, - }); - } catch (error) { - const { bindings: committedBindings, committedCurrentBindingCount } = - collectCommittedBindings( - moduleLinked ? module : null, - priorBindings, - currentBindings, - committedCurrentBindingNames, - ); - // Preserve the last successfully linked module across link-time failures. - // A module whose link step failed cannot safely back @prev because reading - // its namespace throws before evaluation ever begins. Likewise, if a - // linked module failed before its prelude recreated carried bindings, keep - // the old module so @prev still points at the last cell whose prelude and - // body actually established the carried values. Once the prelude has run, - // promote the failed module even if it only updated existing bindings. - if ( - module && - moduleLinked && - (committedCurrentBindingCount > 0 || - (preludeCompleted && priorBindings.length > 0)) - ) { - previousModule = module; - previousBindings = committedBindings; - } - send({ - type: "exec_result", - id: message.id, - ok: false, - output: "", - error: error && error.message ? error.message : String(error), - }); - } finally { - if (activeExecId === message.id) { - activeExecId = null; - } - } -} - -function handleToolResult(message) { - const resolver = pendingTool.get(message.id); - if (resolver) { - pendingTool.delete(message.id); - resolver(message); - } -} - -function handleEmitImageResult(message) { - const resolver = pendingEmitImage.get(message.id); - if (resolver) { - pendingEmitImage.delete(message.id); - resolver(message); - } -} - -let queue = Promise.resolve(); -let pendingInputSegments = []; - -process.on("uncaughtException", (error) => { - scheduleFatalExit("uncaught exception", error); -}); - -process.on("unhandledRejection", (reason) => { - scheduleFatalExit("unhandled rejection", reason); -}); - -function handleInputLine(line) { - if (!line.trim()) { - return; - } - - let message; - try { - message = JSON.parse(line); - } catch { - return; - } - - if (message.type === "exec") { - queue = queue.then(() => handleExec(message)); - return; - } - if (message.type === "run_tool_result") { - handleToolResult(message); - return; - } - if (message.type === "emit_image_result") { - handleEmitImageResult(message); - } -} - -function takePendingInputFrame() { - if (pendingInputSegments.length === 0) { - return null; - } - - // Keep raw stdin chunks queued until a full JSONL frame is ready so we only - // assemble the frame bytes once. - const frame = - pendingInputSegments.length === 1 - ? pendingInputSegments[0] - : Buffer.concat(pendingInputSegments); - pendingInputSegments = []; - return frame; -} - -function handleInputFrame(frame) { - if (!frame) { - return; - } - - if (frame[frame.length - 1] === 0x0d) { - frame = frame.subarray(0, frame.length - 1); - } - handleInputLine(frame.toString("utf8")); -} - -process.stdin.on("data", (chunk) => { - const input = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); - let segmentStart = 0; - let frameEnd = input.indexOf(0x0a); - while (frameEnd !== -1) { - pendingInputSegments.push(input.subarray(segmentStart, frameEnd)); - handleInputFrame(takePendingInputFrame()); - segmentStart = frameEnd + 1; - frameEnd = input.indexOf(0x0a, segmentStart); - } - if (segmentStart < input.length) { - pendingInputSegments.push(input.subarray(segmentStart)); - } -}); - -process.stdin.on("end", () => { - handleInputFrame(takePendingInputFrame()); -}); diff --git a/codex-rs/core/src/tools/js_repl/meriyah.umd.min.js b/codex-rs/core/src/tools/js_repl/meriyah.umd.min.js deleted file mode 100644 index e853b9b29e..0000000000 --- a/codex-rs/core/src/tools/js_repl/meriyah.umd.min.js +++ /dev/null @@ -1,6 +0,0 @@ -/*! - * Meriyah v7.0.0 - * Source: npm package meriyah@7.0.0 (dist/meriyah.umd.min.js) - * License: ISC (see third_party/meriyah/LICENSE) - */ -!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).meriyah={})}(this,(function(e){"use strict";const t=((e,t)=>{const r=new Uint32Array(69632);let n=0,o=0;for(;n<2597;){const a=e[n++];if(a<0)o-=a;else{let i=e[n++];2&a&&(i=t[i]),1&a?r.fill(i,o,o+=e[n++]):r[o++]=i}}return r})([-1,2,26,2,27,2,5,-1,0,77595648,3,44,2,3,0,14,2,61,2,62,3,0,3,0,3168796671,0,4294956992,2,1,2,0,2,41,3,0,4,0,4294966523,3,0,4,2,16,2,63,2,0,0,4294836735,0,3221225471,0,4294901942,2,64,0,134152192,3,0,2,0,4294951935,3,0,2,0,2683305983,0,2684354047,2,17,2,0,0,4294961151,3,0,2,2,19,2,0,0,608174079,2,0,2,58,2,7,2,6,0,4286643967,3,0,2,2,1,3,0,3,0,4294901711,2,40,0,4089839103,0,2961209759,0,1342439375,0,4294543342,0,3547201023,0,1577204103,0,4194240,0,4294688750,2,2,0,80831,0,4261478351,0,4294549486,2,2,0,2967484831,0,196559,0,3594373100,0,3288319768,0,8469959,0,65472,2,3,0,4093640191,0,929054175,0,65487,0,4294828015,0,4092591615,0,1885355487,0,982991,2,3,2,0,0,2163244511,0,4227923919,0,4236247022,2,69,0,4284449919,0,851904,2,4,2,12,0,67076095,-1,2,70,0,1073741743,0,4093607775,-1,0,50331649,0,3265266687,2,33,0,4294844415,0,4278190047,2,20,2,137,-1,3,0,2,2,23,2,0,2,9,2,0,2,15,2,22,3,0,10,2,72,2,0,2,73,2,74,2,75,2,0,2,76,2,0,2,11,0,261632,2,25,3,0,2,2,13,2,4,3,0,18,2,77,2,5,3,0,2,2,78,0,2151677951,2,29,2,10,0,909311,3,0,2,0,814743551,2,48,0,67090432,3,0,2,2,42,2,0,2,6,2,0,2,30,2,8,0,268374015,2,108,2,51,2,0,2,79,0,134153215,-1,2,7,2,0,2,8,0,2684354559,0,67044351,0,3221160064,2,9,2,18,3,0,2,2,53,0,1046528,3,0,3,2,10,2,0,2,127,0,4294960127,2,9,2,6,2,11,0,4294377472,2,12,3,0,16,2,13,2,0,2,80,2,9,2,0,2,81,2,82,2,83,0,12288,2,54,0,1048577,2,84,2,14,-1,2,14,0,131042,2,85,2,86,2,87,2,0,2,34,-83,3,0,7,0,1046559,2,0,2,15,2,0,0,2147516671,2,21,3,88,2,2,0,-16,2,89,0,524222462,2,4,2,0,0,4269801471,2,4,3,0,2,2,28,2,16,3,0,2,2,49,2,0,-1,2,17,-16,3,0,206,-2,3,0,692,2,71,-1,2,17,2,9,3,0,8,2,91,2,18,2,0,0,3220242431,3,0,3,2,19,2,92,2,93,3,0,2,2,94,2,0,2,20,2,95,2,0,0,4351,2,0,2,10,3,0,2,0,67043391,0,3909091327,2,0,2,24,2,10,2,20,3,0,2,0,67076097,2,8,2,0,2,21,0,67059711,0,4236247039,3,0,2,0,939524103,0,8191999,2,99,2,100,2,22,2,23,3,0,3,0,67057663,3,0,349,2,101,2,102,2,7,-264,3,0,11,2,24,3,0,2,2,32,-1,0,3774349439,2,103,2,104,3,0,2,2,19,2,105,3,0,10,2,9,2,17,2,0,2,46,2,0,2,31,2,106,2,25,0,1638399,0,57344,2,107,3,0,3,2,20,2,26,2,27,2,5,2,28,2,0,2,8,2,109,-1,2,110,2,111,2,112,-1,3,0,3,2,12,-2,2,0,2,29,-3,0,536870912,-4,2,20,2,0,2,36,0,1,2,0,2,65,2,6,2,12,2,9,2,0,2,113,-1,3,0,4,2,9,2,23,2,114,2,7,2,0,2,115,2,0,2,116,2,117,2,118,2,0,2,10,3,0,9,2,21,2,30,2,31,2,119,2,120,-2,2,121,2,122,2,30,2,21,2,8,-2,2,123,2,30,3,32,2,-1,2,0,2,39,-2,0,4277137519,0,2269118463,-1,3,20,2,-1,2,33,2,38,2,0,3,30,2,2,35,2,19,-3,3,0,2,2,34,-1,2,0,2,35,2,0,2,35,2,0,2,47,2,0,0,4294950463,2,37,-7,2,0,0,203775,2,125,0,4227858432,2,20,2,43,2,36,2,17,2,37,2,17,2,124,2,21,3,0,2,2,38,0,2151677888,2,0,2,12,0,4294901764,2,145,2,0,2,56,2,55,0,5242879,3,0,2,0,402644511,-1,2,128,2,39,0,3,-1,2,129,2,130,2,0,0,67045375,2,40,0,4226678271,0,3766565279,0,2039759,2,132,2,41,0,1046437,0,6,3,0,2,0,3288270847,0,3,3,0,2,0,67043519,-5,2,0,0,4282384383,0,1056964609,-1,3,0,2,0,67043345,-1,2,0,2,42,2,23,2,50,2,11,2,59,2,38,-5,2,0,2,12,-3,3,0,2,0,2147484671,2,133,0,4190109695,2,52,-2,2,134,0,4244635647,0,27,2,0,2,8,2,43,2,0,2,66,2,17,2,0,2,42,-3,2,31,-2,2,0,2,45,2,57,2,44,2,45,2,135,2,46,0,8388351,-2,2,136,0,3028287487,2,47,2,138,0,33259519,2,23,2,7,2,48,-7,2,21,0,4294836223,0,3355443199,0,134152199,-2,2,67,-2,3,0,28,2,32,-3,3,0,3,2,49,3,0,6,2,50,-81,2,17,3,0,2,2,36,3,0,33,2,25,2,30,3,0,124,2,12,3,0,18,2,38,-213,2,0,2,32,-54,3,0,17,2,42,2,8,2,23,2,0,2,8,2,23,2,51,2,0,2,21,2,52,2,139,2,25,-13,2,0,2,53,-6,3,0,2,-1,2,140,2,10,-1,3,0,2,0,4294936575,2,0,0,4294934783,-2,0,8323099,3,0,230,2,30,2,54,2,8,-3,3,0,3,2,35,-271,2,141,3,0,9,2,142,2,143,2,55,3,0,11,2,7,-72,3,0,3,2,144,0,1677656575,-130,2,26,-16,2,0,2,24,2,38,-16,0,4161266656,0,4071,0,15360,-4,0,28,-13,3,0,2,2,56,2,0,2,146,2,147,2,60,2,0,2,148,2,149,2,150,3,0,10,2,151,2,152,2,22,3,56,2,3,153,2,3,57,2,0,4294954999,2,0,-16,2,0,2,90,2,0,0,2105343,0,4160749584,0,65534,-34,2,8,2,155,-6,0,4194303871,0,4294903771,2,0,2,58,2,98,-3,2,0,0,1073684479,0,17407,-9,2,17,2,49,2,0,2,32,-14,2,17,2,32,-6,2,17,2,12,-6,2,8,0,3225419775,-7,2,156,3,0,6,0,8323103,-1,3,0,2,2,59,-37,2,60,2,157,2,158,2,159,2,160,2,161,-105,2,26,-32,3,0,1335,-1,3,0,136,2,9,3,0,180,2,24,3,0,233,2,162,3,0,18,2,9,-77,3,0,16,2,9,-47,3,0,154,2,6,3,0,264,2,32,-22116,3,0,7,2,25,-6130,3,5,2,-1,0,69207040,3,44,2,3,0,14,2,61,2,62,-3,0,3168731136,0,4294956864,2,1,2,0,2,41,3,0,4,0,4294966275,3,0,4,2,16,2,63,2,0,2,34,-1,2,17,2,64,-1,2,0,0,2047,0,4294885376,3,0,2,0,3145727,0,2617294944,0,4294770688,2,25,2,65,3,0,2,0,131135,2,96,0,70256639,0,71303167,0,272,2,42,2,6,0,65279,2,0,2,48,-1,2,97,2,66,0,4278255616,0,4294836227,0,4294549473,0,600178175,0,2952806400,0,268632067,0,4294543328,0,57540095,0,1577058304,0,1835008,0,4294688736,2,68,2,67,0,33554435,2,131,2,68,0,2952790016,0,131075,0,3594373096,0,67094296,2,67,-1,0,4294828e3,0,603979263,0,922746880,0,3,0,4294828001,0,602930687,0,1879048192,0,393219,0,4294828016,0,671088639,0,2154840064,0,4227858435,0,4236247008,2,69,2,38,-1,2,4,0,917503,2,38,-1,2,70,0,537788335,0,4026531935,-1,0,1,-1,2,33,2,71,0,7936,-3,2,0,0,2147485695,0,1010761728,0,4292984930,0,16387,2,0,2,15,2,22,3,0,10,2,72,2,0,2,73,2,74,2,75,2,0,2,76,2,0,2,12,-1,2,25,3,0,2,2,13,2,4,3,0,18,2,77,2,5,3,0,2,2,78,0,2147745791,3,19,2,0,122879,2,0,2,10,0,276824064,-2,3,0,2,2,42,2,0,0,4294903295,2,0,2,30,2,8,-1,2,17,2,51,2,0,2,79,2,48,-1,2,21,2,0,2,29,-2,0,128,-2,2,28,2,10,0,8160,-1,2,126,0,4227907585,2,0,2,37,2,0,2,50,0,4227915776,2,9,2,6,2,11,-1,0,74440192,3,0,6,-2,3,0,8,2,13,2,0,2,80,2,9,2,0,2,81,2,82,2,83,-3,2,84,2,14,-3,2,85,2,86,2,87,2,0,2,34,-83,3,0,7,0,817183,2,0,2,15,2,0,0,33023,2,21,3,88,2,-17,2,89,0,524157950,2,4,2,0,2,90,2,4,2,0,2,22,2,28,2,16,3,0,2,2,49,2,0,-1,2,17,-16,3,0,206,-2,3,0,692,2,71,-1,2,17,2,9,3,0,8,2,91,0,3072,2,0,0,2147516415,2,9,3,0,2,2,25,2,92,2,93,3,0,2,2,94,2,0,2,20,2,95,0,4294965179,0,7,2,0,2,10,2,93,2,10,-1,0,1761345536,2,96,0,4294901823,2,38,2,20,2,97,2,35,2,98,0,2080440287,2,0,2,34,2,154,0,3296722943,2,0,0,1046675455,0,939524101,0,1837055,2,99,2,100,2,22,2,23,3,0,3,0,7,3,0,349,2,101,2,102,2,7,-264,3,0,11,2,24,3,0,2,2,32,-1,0,2700607615,2,103,2,104,3,0,2,2,19,2,105,3,0,10,2,9,2,17,2,0,2,46,2,0,2,31,2,106,-3,2,107,3,0,3,2,20,-1,3,5,2,2,108,2,0,2,8,2,109,-1,2,110,2,111,2,112,-1,3,0,3,2,12,-2,2,0,2,29,-8,2,20,2,0,2,36,-1,2,0,2,65,2,6,2,30,2,9,2,0,2,113,-1,3,0,4,2,9,2,17,2,114,2,7,2,0,2,115,2,0,2,116,2,117,2,118,2,0,2,10,3,0,9,2,21,2,30,2,31,2,119,2,120,-2,2,121,2,122,2,30,2,21,2,8,-2,2,123,2,30,3,32,2,-1,2,0,2,39,-2,0,4277075969,2,30,-1,3,20,2,-1,2,33,2,124,2,0,3,30,2,2,35,2,19,-3,3,0,2,2,34,-1,2,0,2,35,2,0,2,35,2,0,2,50,2,96,0,4294934591,2,37,-7,2,0,0,197631,2,125,-1,2,20,2,43,2,37,2,17,0,3,2,17,2,124,2,21,2,126,2,127,-1,0,2490368,2,126,2,25,2,17,2,34,2,126,2,38,0,4294901904,0,4718591,2,126,2,35,0,335544350,-1,2,128,0,2147487743,0,1,-1,2,129,2,130,2,8,-1,2,131,2,68,0,3758161920,0,3,2,132,0,12582911,0,655360,-1,2,0,2,29,0,2147485568,0,3,2,0,2,25,0,176,-5,2,0,2,49,0,251658240,-1,2,0,2,25,0,16,-1,2,0,0,16779263,-2,2,12,-1,2,38,-5,2,0,2,18,-3,3,0,2,2,54,2,133,0,2147549183,0,2,-2,2,134,2,36,0,10,0,4294965249,0,67633151,0,4026597376,2,0,0,536871935,2,17,2,0,2,42,-6,2,0,0,1,2,57,2,49,0,1,2,135,2,25,-3,2,136,2,36,2,137,2,138,0,16778239,2,17,2,7,-8,2,35,0,4294836212,2,10,-3,2,67,-2,3,0,28,2,32,-3,3,0,3,2,49,3,0,6,2,50,-81,2,17,3,0,2,2,36,3,0,33,2,25,0,126,3,0,124,2,12,3,0,18,2,38,-213,2,9,-55,3,0,17,2,42,2,8,2,17,2,0,2,8,2,17,2,58,2,0,2,25,2,50,2,139,2,25,-13,2,0,2,71,-6,3,0,2,-1,2,140,2,10,-1,3,0,2,0,67583,-1,2,105,-2,0,8126475,3,0,230,2,30,2,54,2,8,-3,3,0,3,2,35,-271,2,141,3,0,9,2,142,2,143,2,55,3,0,11,2,7,-72,3,0,3,2,144,2,145,-187,3,0,2,2,56,2,0,2,146,2,147,2,60,2,0,2,148,2,149,2,150,3,0,10,2,151,2,152,2,22,3,56,2,3,153,2,3,57,2,2,154,-57,2,8,2,155,-7,2,17,2,0,2,58,-4,2,0,0,1065361407,0,16384,-9,2,17,2,58,2,0,2,18,-14,2,17,2,18,-6,2,17,0,81919,-6,2,8,0,3223273399,-7,2,156,3,0,6,2,124,-1,3,0,2,0,2063,-37,2,60,2,157,2,158,2,159,2,160,2,161,-138,3,0,1335,-1,3,0,136,2,9,3,0,180,2,24,3,0,233,2,162,3,0,18,2,9,-77,3,0,16,2,9,-47,3,0,154,2,6,3,0,264,2,32,-28252],[4294967295,4294967291,4092460543,4294828031,4294967294,134217726,4294903807,268435455,2147483647,1073741823,1048575,3892314111,134217727,1061158911,536805376,4294910143,4294901759,4294901760,4095,262143,536870911,8388607,4160749567,4294902783,4294918143,65535,67043328,2281701374,4294967264,2097151,4194303,255,67108863,4294967039,511,524287,131071,63,127,3238002687,4294549487,4290772991,33554431,4294901888,4286578687,67043329,4294770687,67043583,1023,32767,15,2047999,67043343,67051519,2147483648,4294902e3,4292870143,4294966783,16383,67047423,4294967279,262083,20511,41943039,493567,4294959104,603979775,65536,602799615,805044223,4294965206,8191,1031749119,4294917631,2134769663,4286578493,4282253311,4294942719,33540095,4294905855,2868854591,1608515583,265232348,534519807,2147614720,1060109444,4093640016,17376,2139062143,224,4169138175,4294909951,4286578688,4294967292,4294965759,4294836224,4294966272,4294967280,32768,8289918,4294934399,4294901775,4294965375,1602223615,4294967259,4294443008,268369920,4292804608,4294967232,486341884,4294963199,3087007615,1073692671,4128527,4279238655,4294902015,4160684047,4290246655,469499899,4294967231,134086655,4294966591,2445279231,3670015,31,252,4294967288,16777215,4294705151,3221208447,4294902271,4294549472,4294921215,4285526655,4294966527,4294705152,4294966143,64,4294966719,3774873592,4194303999,1877934080,262151,2555904,536807423,67043839,3758096383,3959414372,3755993023,2080374783,4294835295,4294967103,4160749565,4294934527,4087,2016,2147446655,184024726,2862017156,1593309078,268434431,268434414,4294901761]),r=e=>!!(1&t[34816+(e>>>5)]>>>e),n=[0,0,0,0,0,0,0,0,0,0,1032,0,0,2056,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8192,0,3,0,0,8192,0,0,0,256,0,33024,0,0,242,242,114,114,114,114,114,114,594,594,0,0,16384,0,0,0,0,67,67,67,67,67,67,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,0,1,0,0,4099,0,71,71,71,71,71,71,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,16384,0,0,0,0],o=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0],a=[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,1,0,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,0,0,0,0,0];function i(e){return e<=127?o[e]>0:r(e)}function s(e){return e<=127?a[e]>0:(e=>!!(1&t[0+(e>>>5)]>>>e))(e)||8204===e||8205===e}function c(e){return e.column++,e.currentChar=e.source.charCodeAt(++e.index)}function l(e){const t=e.currentChar;if(55296!=(64512&t))return 0;const r=e.source.charCodeAt(e.index+1);return 56320!=(64512&r)?0:65536+((1023&t)<<10)+(1023&r)}function u(e,t){e.currentChar=e.source.charCodeAt(++e.index),e.flags|=1,4&t||(e.column=0,e.line++)}function p(e){e.flags|=1,e.currentChar=e.source.charCodeAt(++e.index),e.column=0,e.line++}function d(e){return e<65?e-48:e-65+10&15}function g(e){switch(e){case 134283266:return"NumericLiteral";case 134283267:return"StringLiteral";case 86021:case 86022:return"BooleanLiteral";case 86023:return"NullLiteral";case 65540:return"RegularExpression";case 67174408:case 67174409:case 131:return"TemplateLiteral";default:return 143360&~e?4096&~e?"Punctuator":"Keyword":"Identifier"}}const f=["SingleLine","MultiLine","HTMLOpen","HTMLClose","HashbangComment"];function k(e,t,r,n,o,a){return 2&n&&e.report(0),h(e,t,r,o,a)}function h(e,t,r,o,a){const{index:i}=e;for(e.tokenIndex=e.index,e.tokenLine=e.line,e.tokenColumn=e.column;e.index'",49:"The left-hand side of the arrow can only be destructed through assignment",50:"The binding declaration is not destructible",51:"Async arrow can not be followed by new expression",52:"Classes may not have a static property named 'prototype'",53:"Class constructor may not be a %0",54:"Duplicate constructor method in class",55:"Invalid increment/decrement operand",56:"Invalid use of `new` keyword on an increment/decrement expression",57:"`=>` is an invalid assignment target",58:"Rest element may not have a trailing comma",59:"Missing initializer in %0 declaration",60:"'for-%0' loop head declarations can not have an initializer",61:"Invalid left-hand side in for-%0 loop: Must have a single binding",62:"Invalid shorthand property initializer",63:"Property name __proto__ appears more than once in object literal",64:"Let is disallowed as a lexically bound name",65:"Invalid use of '%0' inside new expression",66:"Illegal 'use strict' directive in function with non-simple parameter list",67:'Identifier "let" disallowed as left-hand side expression in strict mode',68:"Illegal continue statement",69:"Illegal break statement",70:"Cannot have `let[...]` as a var name in strict mode",71:"Invalid destructuring assignment target",72:"Rest parameter may not have a default initializer",73:"The rest argument must the be last parameter",74:"Invalid rest argument",76:"In strict mode code, functions can only be declared at top level or inside a block",77:"In non-strict mode code, functions can only be declared at top level, inside a block, or as the body of an if statement",78:"Without web compatibility enabled functions can not be declared at top level, inside a block, or as the body of an if statement",79:"Class declaration can't appear in single-statement context",80:"Invalid left-hand side in for-%0",81:"Invalid assignment in for-%0",82:"for await (... of ...) is only valid in async functions and async generators",83:"The first token after the template expression should be a continuation of the template",85:"`let` declaration not allowed here and `let` cannot be a regular var name in strict mode",84:"`let \n [` is a restricted production at the start of a statement",86:"Catch clause requires exactly one parameter, not more (and no trailing comma)",87:"Catch clause parameter does not support default values",88:"Missing catch or finally after try",89:"More than one default clause in switch statement",90:"Illegal newline after throw",91:"Strict mode code may not include a with statement",92:"Illegal return statement",93:"The left hand side of the for-header binding declaration is not destructible",94:"new.target only allowed within functions or static blocks",96:"'#' not followed by identifier",102:"Invalid keyword",101:"Can not use 'let' as a class name",100:"'A lexical declaration can't define a 'let' binding",99:"Can not use `let` as variable name in strict mode",97:"'%0' may not be used as an identifier in this context",98:"Await is only valid in async functions",103:"The %0 keyword can only be used with the module goal",104:"Unicode codepoint must not be greater than 0x10FFFF",105:"%0 source must be string",106:"Only a identifier or string can be used to indicate alias",107:"Only '*' or '{...}' can be imported after default",108:"Trailing decorator may be followed by method",109:"Decorators can't be used with a constructor",110:"Can not use `await` as identifier in module or async func",111:"Can not use `await` as identifier in module",112:"HTML comments are only allowed with web compatibility (Annex B)",113:"The identifier 'let' must not be in expression position in strict mode",114:"Cannot assign to `eval` and `arguments` in strict mode",115:"The left-hand side of a for-of loop may not start with 'let'",116:"Block body arrows can not be immediately invoked without a group",117:"Block body arrows can not be immediately accessed without a group",118:"Unexpected strict mode reserved word",119:"Unexpected eval or arguments in strict mode",120:"Decorators must not be followed by a semicolon",121:"Calling delete on expression not allowed in strict mode",122:"Pattern can not have a tail",124:"Can not have a `yield` expression on the left side of a ternary",125:"An arrow function can not have a postfix update operator",126:"Invalid object literal key character after generator star",127:"Private fields can not be deleted",129:"Classes may not have a field called constructor",128:"Classes may not have a private element named constructor",130:"A class field initializer or static block may not contain arguments",131:"Generators can only be declared at the top level or inside a block",132:"Async methods are a restricted production and cannot have a newline following it",133:"Unexpected character after object literal property name",135:"Invalid key token",136:"Label '%0' has already been declared",137:"continue statement must be nested within an iteration statement",138:"Undefined label '%0'",139:"Trailing comma is disallowed inside import(...) arguments",140:"Invalid binding in JSON import",141:"import() requires exactly one argument",142:"Cannot use new with import(...)",143:"... is not allowed in import()",144:"Expected '=>'",145:"Duplicate binding '%0'",146:"Duplicate private identifier #%0",147:"Cannot export a duplicate name '%0'",150:"Duplicate %0 for-binding",148:"Exported binding '%0' needs to refer to a top-level declared variable",149:"Unexpected private field",153:"Numeric separators are not allowed at the end of numeric literals",152:"Only one underscore is allowed as numeric separator",154:"JSX value should be either an expression or a quoted JSX text",155:"Expected corresponding JSX closing tag for %0",156:"Adjacent JSX elements must be wrapped in an enclosing tag",157:"JSX attributes must only be assigned a non-empty 'expression'",158:"'%0' has already been declared",159:"'%0' shadowed a catch clause binding",160:"Dot property must be an identifier",161:"Encountered invalid input after spread/rest argument",162:"Catch without try",163:"Finally without try",164:"Expected corresponding closing tag for JSX fragment",165:"Coalescing and logical operators used together in the same expression must be disambiguated with parentheses",166:"Invalid tagged template on optional chain",167:"Invalid optional chain from super property",168:"Invalid optional chain from new expression",169:'Cannot use "import.meta" outside a module',170:"Leading decorators must be attached to a class declaration",171:"An export name cannot include a lone surrogate",172:"A string literal cannot be used as an exported binding without `from`",173:"Private fields can't be accessed on super",174:"The only valid meta property for import is 'import.meta'",175:"'import.meta' must not contain escaped characters",176:'cannot use "await" as identifier inside an async function',177:'cannot use "await" in static blocks'};class T extends SyntaxError{start;end;range;loc;description;constructor(e,t,r,...n){const o=b[r].replace(/%(\d+)/g,((e,t)=>n[t]));super("["+e.line+":"+e.column+"-"+t.line+":"+t.column+"]: "+o),this.start=e.index,this.end=t.index,this.range=[e.index,t.index],this.loc={start:{line:e.line,column:e.column},end:{line:t.line,column:t.column}},this.description=o}}function y(e,t){return Object.hasOwn(e,t)?e[t]:void 0}const x=["end of source","identifier","number","string","regular expression","false","true","null","template continuation","template tail","=>","(","{",".","...","}",")",";",",","[","]",":","?","'",'"',"++","--","=","<<=",">>=",">>>=","**=","+=","-=","*=","/=","%=","^=","|=","&=","||=","&&=","??=","typeof","delete","void","!","~","+","-","in","instanceof","*","%","/","**","&&","||","===","!==","==","!=","<=",">=","<",">","<<",">>",">>>","&","|","^","var","let","const","break","case","catch","class","continue","debugger","default","do","else","export","extends","finally","for","function","if","import","new","return","super","switch","this","throw","try","while","with","implements","interface","package","private","protected","public","static","yield","as","async","await","constructor","get","set","accessor","from","of","enum","eval","arguments","escaped keyword","escaped future reserved keyword","reserved if strict","#","BigIntLiteral","??","?.","WhiteSpace","Illegal","LineTerminator","PrivateField","Template","@","target","meta","LineFeed","Escaped","JSXText"],w={this:86111,function:86104,if:20569,return:20572,var:86088,else:20563,for:20567,new:86107,in:8673330,typeof:16863275,while:20578,case:20556,break:20555,try:20577,catch:20557,delete:16863276,throw:86112,switch:86110,continue:20559,default:20561,instanceof:8411187,do:20562,void:16863277,finally:20566,async:209005,await:209006,class:86094,const:86090,constructor:12399,debugger:20560,export:20564,extends:20565,false:86021,from:209011,get:209008,implements:36964,import:86106,interface:36965,let:241737,null:86023,of:471156,package:36966,private:36967,protected:36968,public:36969,set:209009,static:36970,super:86109,true:86022,with:20579,yield:241771,enum:86133,eval:537079926,as:77932,arguments:537079927,target:209029,meta:209030,accessor:12402};function S(e,t,r){for(;a[c(e)];);return e.tokenValue=e.source.slice(e.tokenIndex,e.index),92!==e.currentChar&&e.currentChar<=126?y(w,e.tokenValue)??208897:C(e,t,0,r)}function v(e,t){const r=E(e);return i(r)||e.report(5),e.tokenValue=String.fromCodePoint(r),C(e,t,1,4&n[r])}function C(e,t,r,o){let a=e.index;for(;e.index0)s(t)||e.report(20,String.fromCodePoint(t)),e.currentChar=t,e.index++,e.column++;else if(!s(e.currentChar))break;c(e)}e.index<=e.end&&(e.tokenValue+=e.source.slice(a,e.index));const{length:i}=e.tokenValue;if(o&&i>=2&&i<=11){const n=y(w,e.tokenValue);return void 0===n?208897|(r?-2147483648:0):r?209006===n?2050&t?-2147483528:-2147483648|n:1&t?36970===n?-2147483527:36864&~n?20480&~n?-2147274630:262144&t&&!(8&t)?-2147483648|n:-2147483528:-2147483527:!(262144&t)||8&t||20480&~n?241771===n?262144&t?-2147274630:1024&t?-2147483528:-2147483648|n:209005===n?-2147274630:36864&~n?-2147483528:12288|n|-2147483648:-2147483648|n:n}return 208897|(r?-2147483648:0)}function q(e){let t=c(e);if(92===t)return 130;const r=l(e);return r&&(t=r),i(t)||e.report(96),130}function E(e){return 117!==e.source.charCodeAt(e.index+1)&&e.report(5),e.currentChar=e.source.charCodeAt(e.index+=2),e.column+=2,function(e){let t=0;const r=e.currentChar;if(123===r){const r=e.index-2;for(;64&n[c(e)];)if(t=t<<4|d(e.currentChar),t>1114111)throw new T({index:r,line:e.line,column:e.column},e.currentLocation,104);if(125!==e.currentChar)throw new T({index:r,line:e.line,column:e.column},e.currentLocation,7);return c(e),t}64&n[r]||e.report(7);const o=e.source.charCodeAt(e.index+1);64&n[o]||e.report(7);const a=e.source.charCodeAt(e.index+2);64&n[a]||e.report(7);const i=e.source.charCodeAt(e.index+3);64&n[i]||e.report(7);return t=d(r)<<12|d(o)<<8|d(a)<<4|d(i),e.currentChar=e.source.charCodeAt(e.index+=4),e.column+=4,t}(e)}function N(e,t,r){let o=e.currentChar,a=0,s=9,l=64&r?0:1,u=0,p=0;if(64&r)a="."+L(e,o),o=e.currentChar,110===o&&e.report(12);else{if(48===o)if(o=c(e),120==(32|o)){for(r=136,o=c(e);4160&n[o];)95!==o?(p=1,a=16*a+d(o),u++,o=c(e)):(p||e.report(152),p=0,o=c(e));0!==u&&p||e.report(0===u?21:153)}else if(111==(32|o)){for(r=132,o=c(e);4128&n[o];)95!==o?(p=1,a=8*a+(o-48),u++,o=c(e)):(p||e.report(152),p=0,o=c(e));0!==u&&p||e.report(0===u?0:153)}else if(98==(32|o)){for(r=130,o=c(e);4224&n[o];)95!==o?(p=1,a=2*a+(o-48),u++,o=c(e)):(p||e.report(152),p=0,o=c(e));0!==u&&p||e.report(0===u?0:153)}else if(32&n[o])for(1&t&&e.report(1),r=1;16&n[o];){if(512&n[o]){r=32,l=0;break}a=8*a+(o-48),o=c(e)}else 512&n[o]?(1&t&&e.report(1),e.flags|=64,r=32):95===o&&e.report(0);if(48&r){if(l){for(;s>=0&&4112&n[o];)if(95!==o)p=0,a=10*a+(o-48),o=c(e),--s;else{if(o=c(e),95===o||32&r)throw new T(e.currentLocation,{index:e.index+1,line:e.line,column:e.column},152);p=1}if(p)throw new T(e.currentLocation,{index:e.index+1,line:e.line,column:e.column},153);if(s>=0&&!i(o)&&46!==o)return e.tokenValue=a,e.options.raw&&(e.tokenRaw=e.source.slice(e.tokenIndex,e.index)),134283266}a+=L(e,o),o=e.currentChar,46===o&&(95===c(e)&&e.report(0),r=64,a+="."+L(e,e.currentChar),o=e.currentChar)}}const g=e.index;let f=0;if(110===o&&128&r)f=1,o=c(e);else if(101==(32|o)){o=c(e),256&n[o]&&(o=c(e));const{index:t}=e;16&n[o]||e.report(11),a+=e.source.substring(g,t)+L(e,o),o=e.currentChar}return(e.index=e.source.length)return e.report(34)}const n=e.index-1;let o=I.Empty,a=e.currentChar;const{index:i}=e;for(;s(a);){switch(a){case 103:o&I.Global&&e.report(36,"g"),o|=I.Global;break;case 105:o&I.IgnoreCase&&e.report(36,"i"),o|=I.IgnoreCase;break;case 109:o&I.Multiline&&e.report(36,"m"),o|=I.Multiline;break;case 117:o&I.Unicode&&e.report(36,"u"),o&I.UnicodeSets&&e.report(36,"vu"),o|=I.Unicode;break;case 118:o&I.Unicode&&e.report(36,"uv"),o&I.UnicodeSets&&e.report(36,"v"),o|=I.UnicodeSets;break;case 121:o&I.Sticky&&e.report(36,"y"),o|=I.Sticky;break;case 115:o&I.DotAll&&e.report(36,"s"),o|=I.DotAll;break;case 100:o&I.Indices&&e.report(36,"d"),o|=I.Indices;break;default:e.report(35)}a=c(e)}const l=e.source.slice(i,e.index),u=e.source.slice(t,n);return e.tokenRegExp={pattern:u,flags:l},e.options.raw&&(e.tokenRaw=e.source.slice(e.tokenIndex,e.index)),e.tokenValue=function(e,t,r){try{return new RegExp(t,r)}catch{if(!e.options.validateRegex)return null;e.report(34)}}(e,u,l),65540}function D(e,t,r){const{index:o}=e;let a="",i=c(e),s=e.index;for(;!(8&n[i]);){if(i===r)return a+=e.source.slice(s,e.index),c(e),e.options.raw&&(e.tokenRaw=e.source.slice(o,e.index)),e.tokenValue=a,134283267;if(8&~i||92!==i)8232!==i&&8233!==i||(e.column=-1,e.line++);else{if(a+=e.source.slice(s,e.index),i=c(e),i<127||8232===i||8233===i){const r=R(e,t,i);r>=0?a+=String.fromCodePoint(r):B(e,r,0)}else a+=String.fromCodePoint(i);s=e.index+1}e.index>=e.end&&e.report(16),i=c(e)}e.report(16)}function R(e,t,r,o=0){switch(r){case 98:return 8;case 102:return 12;case 114:return 13;case 110:return 10;case 116:return 9;case 118:return 11;case 13:if(e.index1114111)return-5;return e.currentChar<1||125!==e.currentChar?-4:t}{if(!(64&n[t]))return-4;const r=e.source.charCodeAt(e.index+1);if(!(64&n[r]))return-4;const o=e.source.charCodeAt(e.index+2);if(!(64&n[o]))return-4;const a=e.source.charCodeAt(e.index+3);return 64&n[a]?(e.index+=3,e.column+=3,e.currentChar=e.source.charCodeAt(e.index),d(t)<<12|d(r)<<8|d(o)<<4|d(a)):-4}}case 56:case 57:if(o||!e.options.webcompat||1&t)return-3;e.flags|=4096;default:return r}}function B(e,t,r){switch(t){case-1:return;case-2:e.report(r?2:1);case-3:e.report(r?3:14);case-4:e.report(7);case-5:e.report(104)}}function U(e,t){const{index:r}=e;let n=67174409,o="",a=c(e);for(;96!==a;){if(36===a&&123===e.source.charCodeAt(e.index+1)){c(e),n=67174408;break}if(92===a)if(a=c(e),a>126)o+=String.fromCodePoint(a);else{const{index:r,line:i,column:s}=e,c=R(e,1|t,a,1);if(c>=0)o+=String.fromCodePoint(c);else{if(-1!==c&&64&t){e.index=r,e.line=i,e.column=s,o=null,a=P(e,a),a<0&&(n=67174408);break}B(e,c,1)}}else e.index=e.end&&e.report(17),a=c(e)}return c(e),e.tokenValue=o,e.tokenRaw=e.source.slice(r+1,e.index-(67174409===n?1:2)),n}function P(e,t){for(;96!==t;){switch(t){case 36:{const r=e.index+1;if(r=e.end&&e.report(17),t=c(e)}return t}function O(e,t){return e.index>=e.end&&e.report(0),e.index--,e.column--,U(e,t)}!function(e){e[e.Empty=0]="Empty",e[e.Escape=1]="Escape",e[e.Class=2]="Class"}(A||(A={})),function(e){e[e.Empty=0]="Empty",e[e.IgnoreCase=1]="IgnoreCase",e[e.Global=2]="Global",e[e.Multiline=4]="Multiline",e[e.Unicode=16]="Unicode",e[e.Sticky=8]="Sticky",e[e.DotAll=32]="DotAll",e[e.Indices=64]="Indices",e[e.UnicodeSets=128]="UnicodeSets"}(I||(I={}));const G=[128,128,128,128,128,128,128,128,128,127,135,127,127,129,128,128,128,128,128,128,128,128,128,128,128,128,128,128,128,128,128,128,127,16842798,134283267,130,208897,8391477,8390213,134283267,67174411,16,8391476,25233968,18,25233969,67108877,8457014,134283266,134283266,134283266,134283266,134283266,134283266,134283266,134283266,134283266,134283266,21,1074790417,8456256,1077936155,8390721,22,132,208897,208897,208897,208897,208897,208897,208897,208897,208897,208897,208897,208897,208897,208897,208897,208897,208897,208897,208897,208897,208897,208897,208897,208897,208897,208897,69271571,136,20,8389959,208897,131,4096,4096,4096,4096,4096,4096,4096,208897,4096,208897,208897,4096,208897,4096,208897,4096,208897,4096,4096,4096,208897,4096,4096,208897,4096,4096,2162700,8389702,1074790415,16842799,128];function j(e,t){e.flags=1^(1|e.flags),e.startIndex=e.index,e.startColumn=e.column,e.startLine=e.line,e.setToken(F(e,t,0))}function F(e,t,n){const o=0===e.index,{source:a}=e;for(;e.index=e.end)return 8391476;const t=e.currentChar;return 61===t?(c(e),4194338):42!==t?8391476:61!==c(e)?8391735:(c(e),4194335)}case 8389959:return 61!==c(e)?8389959:(c(e),4194341);case 25233968:{c(e);const t=e.currentChar;return 43===t?(c(e),33619993):61===t?(c(e),4194336):25233968}case 25233969:{c(e);const r=e.currentChar;if(45===r){if(c(e),(1&n||o)&&62===e.currentChar){e.options.webcompat||e.report(112),c(e),n=k(e,a,n,t,3,e.tokenStart);continue}return 33619994}return 61===r?(c(e),4194337):25233969}case 8457014:if(c(e),e.index=48&&r<=57)return N(e,t,80);if(46===r){const t=e.index+1;if(t=48&&t<=57)))return c(e),67108990}return 22}}}else{if((8232^s)<=1){n=-5&n|1,p(e);continue}const o=l(e);if(o>0&&(s=o),r(s))return e.tokenValue="",C(e,t,0,0);if(160===(i=s)||65279===i||133===i||5760===i||i>=8192&&i<=8203||8239===i||8287===i||12288===i||8201===i||65519===i){c(e);continue}e.report(20,String.fromCodePoint(s))}}var i;return 1048576}function M(e,t){!(1&e.flags)&&1048576&~e.getToken()&&e.report(30,x[255&e.getToken()]),z(e,t,1074790417)||e.options.onInsertedSemicolon?.(e.startIndex)}function H(e,t,r,n){return t-r<13&&"use strict"===n&&(!(1048576&~e.getToken())||1&e.flags)?1:0}function J(e,t,r){return e.getToken()!==r?0:(j(e,t),1)}function z(e,t,r){return e.getToken()===r&&(j(e,t),!0)}function X(e,t,r){e.getToken()!==r&&e.report(25,x[255&r]),j(e,t)}function _(e,t){switch(t.type){case"ArrayExpression":{t.type="ArrayPattern";const{elements:r}=t;for(let t=0,n=r.length;t",Gamma:"Γ",Gammad:"Ϝ",Gbreve:"Ğ",Gcedil:"Ģ",Gcirc:"Ĝ",Gcy:"Г",Gdot:"Ġ",Gfr:"𝔊",Gg:"⋙",Gopf:"𝔾",GreaterEqual:"≥",GreaterEqualLess:"⋛",GreaterFullEqual:"≧",GreaterGreater:"⪢",GreaterLess:"≷",GreaterSlantEqual:"⩾",GreaterTilde:"≳",Gscr:"𝒢",Gt:"≫",HARDcy:"Ъ",Hacek:"ˇ",Hat:"^",Hcirc:"Ĥ",Hfr:"ℌ",HilbertSpace:"ℋ",Hopf:"ℍ",HorizontalLine:"─",Hscr:"ℋ",Hstrok:"Ħ",HumpDownHump:"≎",HumpEqual:"≏",IEcy:"Е",IJlig:"IJ",IOcy:"Ё",Iacute:"Í",Icirc:"Î",Icy:"И",Idot:"İ",Ifr:"ℑ",Igrave:"Ì",Im:"ℑ",Imacr:"Ī",ImaginaryI:"ⅈ",Implies:"⇒",Int:"∬",Integral:"∫",Intersection:"⋂",InvisibleComma:"⁣",InvisibleTimes:"⁢",Iogon:"Į",Iopf:"𝕀",Iota:"Ι",Iscr:"ℐ",Itilde:"Ĩ",Iukcy:"І",Iuml:"Ï",Jcirc:"Ĵ",Jcy:"Й",Jfr:"𝔍",Jopf:"𝕁",Jscr:"𝒥",Jsercy:"Ј",Jukcy:"Є",KHcy:"Х",KJcy:"Ќ",Kappa:"Κ",Kcedil:"Ķ",Kcy:"К",Kfr:"𝔎",Kopf:"𝕂",Kscr:"𝒦",LJcy:"Љ",LT:"<",Lacute:"Ĺ",Lambda:"Λ",Lang:"⟪",Laplacetrf:"ℒ",Larr:"↞",Lcaron:"Ľ",Lcedil:"Ļ",Lcy:"Л",LeftAngleBracket:"⟨",LeftArrow:"←",LeftArrowBar:"⇤",LeftArrowRightArrow:"⇆",LeftCeiling:"⌈",LeftDoubleBracket:"⟦",LeftDownTeeVector:"⥡",LeftDownVector:"⇃",LeftDownVectorBar:"⥙",LeftFloor:"⌊",LeftRightArrow:"↔",LeftRightVector:"⥎",LeftTee:"⊣",LeftTeeArrow:"↤",LeftTeeVector:"⥚",LeftTriangle:"⊲",LeftTriangleBar:"⧏",LeftTriangleEqual:"⊴",LeftUpDownVector:"⥑",LeftUpTeeVector:"⥠",LeftUpVector:"↿",LeftUpVectorBar:"⥘",LeftVector:"↼",LeftVectorBar:"⥒",Leftarrow:"⇐",Leftrightarrow:"⇔",LessEqualGreater:"⋚",LessFullEqual:"≦",LessGreater:"≶",LessLess:"⪡",LessSlantEqual:"⩽",LessTilde:"≲",Lfr:"𝔏",Ll:"⋘",Lleftarrow:"⇚",Lmidot:"Ŀ",LongLeftArrow:"⟵",LongLeftRightArrow:"⟷",LongRightArrow:"⟶",Longleftarrow:"⟸",Longleftrightarrow:"⟺",Longrightarrow:"⟹",Lopf:"𝕃",LowerLeftArrow:"↙",LowerRightArrow:"↘",Lscr:"ℒ",Lsh:"↰",Lstrok:"Ł",Lt:"≪",Map:"⤅",Mcy:"М",MediumSpace:" ",Mellintrf:"ℳ",Mfr:"𝔐",MinusPlus:"∓",Mopf:"𝕄",Mscr:"ℳ",Mu:"Μ",NJcy:"Њ",Nacute:"Ń",Ncaron:"Ň",Ncedil:"Ņ",Ncy:"Н",NegativeMediumSpace:"​",NegativeThickSpace:"​",NegativeThinSpace:"​",NegativeVeryThinSpace:"​",NestedGreaterGreater:"≫",NestedLessLess:"≪",NewLine:"\n",Nfr:"𝔑",NoBreak:"⁠",NonBreakingSpace:" ",Nopf:"ℕ",Not:"⫬",NotCongruent:"≢",NotCupCap:"≭",NotDoubleVerticalBar:"∦",NotElement:"∉",NotEqual:"≠",NotEqualTilde:"≂̸",NotExists:"∄",NotGreater:"≯",NotGreaterEqual:"≱",NotGreaterFullEqual:"≧̸",NotGreaterGreater:"≫̸",NotGreaterLess:"≹",NotGreaterSlantEqual:"⩾̸",NotGreaterTilde:"≵",NotHumpDownHump:"≎̸",NotHumpEqual:"≏̸",NotLeftTriangle:"⋪",NotLeftTriangleBar:"⧏̸",NotLeftTriangleEqual:"⋬",NotLess:"≮",NotLessEqual:"≰",NotLessGreater:"≸",NotLessLess:"≪̸",NotLessSlantEqual:"⩽̸",NotLessTilde:"≴",NotNestedGreaterGreater:"⪢̸",NotNestedLessLess:"⪡̸",NotPrecedes:"⊀",NotPrecedesEqual:"⪯̸",NotPrecedesSlantEqual:"⋠",NotReverseElement:"∌",NotRightTriangle:"⋫",NotRightTriangleBar:"⧐̸",NotRightTriangleEqual:"⋭",NotSquareSubset:"⊏̸",NotSquareSubsetEqual:"⋢",NotSquareSuperset:"⊐̸",NotSquareSupersetEqual:"⋣",NotSubset:"⊂⃒",NotSubsetEqual:"⊈",NotSucceeds:"⊁",NotSucceedsEqual:"⪰̸",NotSucceedsSlantEqual:"⋡",NotSucceedsTilde:"≿̸",NotSuperset:"⊃⃒",NotSupersetEqual:"⊉",NotTilde:"≁",NotTildeEqual:"≄",NotTildeFullEqual:"≇",NotTildeTilde:"≉",NotVerticalBar:"∤",Nscr:"𝒩",Ntilde:"Ñ",Nu:"Ν",OElig:"Œ",Oacute:"Ó",Ocirc:"Ô",Ocy:"О",Odblac:"Ő",Ofr:"𝔒",Ograve:"Ò",Omacr:"Ō",Omega:"Ω",Omicron:"Ο",Oopf:"𝕆",OpenCurlyDoubleQuote:"“",OpenCurlyQuote:"‘",Or:"⩔",Oscr:"𝒪",Oslash:"Ø",Otilde:"Õ",Otimes:"⨷",Ouml:"Ö",OverBar:"‾",OverBrace:"⏞",OverBracket:"⎴",OverParenthesis:"⏜",PartialD:"∂",Pcy:"П",Pfr:"𝔓",Phi:"Φ",Pi:"Π",PlusMinus:"±",Poincareplane:"ℌ",Popf:"ℙ",Pr:"⪻",Precedes:"≺",PrecedesEqual:"⪯",PrecedesSlantEqual:"≼",PrecedesTilde:"≾",Prime:"″",Product:"∏",Proportion:"∷",Proportional:"∝",Pscr:"𝒫",Psi:"Ψ",QUOT:'"',Qfr:"𝔔",Qopf:"ℚ",Qscr:"𝒬",RBarr:"⤐",REG:"®",Racute:"Ŕ",Rang:"⟫",Rarr:"↠",Rarrtl:"⤖",Rcaron:"Ř",Rcedil:"Ŗ",Rcy:"Р",Re:"ℜ",ReverseElement:"∋",ReverseEquilibrium:"⇋",ReverseUpEquilibrium:"⥯",Rfr:"ℜ",Rho:"Ρ",RightAngleBracket:"⟩",RightArrow:"→",RightArrowBar:"⇥",RightArrowLeftArrow:"⇄",RightCeiling:"⌉",RightDoubleBracket:"⟧",RightDownTeeVector:"⥝",RightDownVector:"⇂",RightDownVectorBar:"⥕",RightFloor:"⌋",RightTee:"⊢",RightTeeArrow:"↦",RightTeeVector:"⥛",RightTriangle:"⊳",RightTriangleBar:"⧐",RightTriangleEqual:"⊵",RightUpDownVector:"⥏",RightUpTeeVector:"⥜",RightUpVector:"↾",RightUpVectorBar:"⥔",RightVector:"⇀",RightVectorBar:"⥓",Rightarrow:"⇒",Ropf:"ℝ",RoundImplies:"⥰",Rrightarrow:"⇛",Rscr:"ℛ",Rsh:"↱",RuleDelayed:"⧴",SHCHcy:"Щ",SHcy:"Ш",SOFTcy:"Ь",Sacute:"Ś",Sc:"⪼",Scaron:"Š",Scedil:"Ş",Scirc:"Ŝ",Scy:"С",Sfr:"𝔖",ShortDownArrow:"↓",ShortLeftArrow:"←",ShortRightArrow:"→",ShortUpArrow:"↑",Sigma:"Σ",SmallCircle:"∘",Sopf:"𝕊",Sqrt:"√",Square:"□",SquareIntersection:"⊓",SquareSubset:"⊏",SquareSubsetEqual:"⊑",SquareSuperset:"⊐",SquareSupersetEqual:"⊒",SquareUnion:"⊔",Sscr:"𝒮",Star:"⋆",Sub:"⋐",Subset:"⋐",SubsetEqual:"⊆",Succeeds:"≻",SucceedsEqual:"⪰",SucceedsSlantEqual:"≽",SucceedsTilde:"≿",SuchThat:"∋",Sum:"∑",Sup:"⋑",Superset:"⊃",SupersetEqual:"⊇",Supset:"⋑",THORN:"Þ",TRADE:"™",TSHcy:"Ћ",TScy:"Ц",Tab:"\t",Tau:"Τ",Tcaron:"Ť",Tcedil:"Ţ",Tcy:"Т",Tfr:"𝔗",Therefore:"∴",Theta:"Θ",ThickSpace:"  ",ThinSpace:" ",Tilde:"∼",TildeEqual:"≃",TildeFullEqual:"≅",TildeTilde:"≈",Topf:"𝕋",TripleDot:"⃛",Tscr:"𝒯",Tstrok:"Ŧ",Uacute:"Ú",Uarr:"↟",Uarrocir:"⥉",Ubrcy:"Ў",Ubreve:"Ŭ",Ucirc:"Û",Ucy:"У",Udblac:"Ű",Ufr:"𝔘",Ugrave:"Ù",Umacr:"Ū",UnderBar:"_",UnderBrace:"⏟",UnderBracket:"⎵",UnderParenthesis:"⏝",Union:"⋃",UnionPlus:"⊎",Uogon:"Ų",Uopf:"𝕌",UpArrow:"↑",UpArrowBar:"⤒",UpArrowDownArrow:"⇅",UpDownArrow:"↕",UpEquilibrium:"⥮",UpTee:"⊥",UpTeeArrow:"↥",Uparrow:"⇑",Updownarrow:"⇕",UpperLeftArrow:"↖",UpperRightArrow:"↗",Upsi:"ϒ",Upsilon:"Υ",Uring:"Ů",Uscr:"𝒰",Utilde:"Ũ",Uuml:"Ü",VDash:"⊫",Vbar:"⫫",Vcy:"В",Vdash:"⊩",Vdashl:"⫦",Vee:"⋁",Verbar:"‖",Vert:"‖",VerticalBar:"∣",VerticalLine:"|",VerticalSeparator:"❘",VerticalTilde:"≀",VeryThinSpace:" ",Vfr:"𝔙",Vopf:"𝕍",Vscr:"𝒱",Vvdash:"⊪",Wcirc:"Ŵ",Wedge:"⋀",Wfr:"𝔚",Wopf:"𝕎",Wscr:"𝒲",Xfr:"𝔛",Xi:"Ξ",Xopf:"𝕏",Xscr:"𝒳",YAcy:"Я",YIcy:"Ї",YUcy:"Ю",Yacute:"Ý",Ycirc:"Ŷ",Ycy:"Ы",Yfr:"𝔜",Yopf:"𝕐",Yscr:"𝒴",Yuml:"Ÿ",ZHcy:"Ж",Zacute:"Ź",Zcaron:"Ž",Zcy:"З",Zdot:"Ż",ZeroWidthSpace:"​",Zeta:"Ζ",Zfr:"ℨ",Zopf:"ℤ",Zscr:"𝒵",aacute:"á",abreve:"ă",ac:"∾",acE:"∾̳",acd:"∿",acirc:"â",acute:"´",acy:"а",aelig:"æ",af:"⁡",afr:"𝔞",agrave:"à",alefsym:"ℵ",aleph:"ℵ",alpha:"α",amacr:"ā",amalg:"⨿",amp:"&",and:"∧",andand:"⩕",andd:"⩜",andslope:"⩘",andv:"⩚",ang:"∠",ange:"⦤",angle:"∠",angmsd:"∡",angmsdaa:"⦨",angmsdab:"⦩",angmsdac:"⦪",angmsdad:"⦫",angmsdae:"⦬",angmsdaf:"⦭",angmsdag:"⦮",angmsdah:"⦯",angrt:"∟",angrtvb:"⊾",angrtvbd:"⦝",angsph:"∢",angst:"Å",angzarr:"⍼",aogon:"ą",aopf:"𝕒",ap:"≈",apE:"⩰",apacir:"⩯",ape:"≊",apid:"≋",apos:"'",approx:"≈",approxeq:"≊",aring:"å",ascr:"𝒶",ast:"*",asymp:"≈",asympeq:"≍",atilde:"ã",auml:"ä",awconint:"∳",awint:"⨑",bNot:"⫭",backcong:"≌",backepsilon:"϶",backprime:"‵",backsim:"∽",backsimeq:"⋍",barvee:"⊽",barwed:"⌅",barwedge:"⌅",bbrk:"⎵",bbrktbrk:"⎶",bcong:"≌",bcy:"б",bdquo:"„",becaus:"∵",because:"∵",bemptyv:"⦰",bepsi:"϶",bernou:"ℬ",beta:"β",beth:"ℶ",between:"≬",bfr:"𝔟",bigcap:"⋂",bigcirc:"◯",bigcup:"⋃",bigodot:"⨀",bigoplus:"⨁",bigotimes:"⨂",bigsqcup:"⨆",bigstar:"★",bigtriangledown:"▽",bigtriangleup:"△",biguplus:"⨄",bigvee:"⋁",bigwedge:"⋀",bkarow:"⤍",blacklozenge:"⧫",blacksquare:"▪",blacktriangle:"▴",blacktriangledown:"▾",blacktriangleleft:"◂",blacktriangleright:"▸",blank:"␣",blk12:"▒",blk14:"░",blk34:"▓",block:"█",bne:"=⃥",bnequiv:"≡⃥",bnot:"⌐",bopf:"𝕓",bot:"⊥",bottom:"⊥",bowtie:"⋈",boxDL:"╗",boxDR:"╔",boxDl:"╖",boxDr:"╓",boxH:"═",boxHD:"╦",boxHU:"╩",boxHd:"╤",boxHu:"╧",boxUL:"╝",boxUR:"╚",boxUl:"╜",boxUr:"╙",boxV:"║",boxVH:"╬",boxVL:"╣",boxVR:"╠",boxVh:"╫",boxVl:"╢",boxVr:"╟",boxbox:"⧉",boxdL:"╕",boxdR:"╒",boxdl:"┐",boxdr:"┌",boxh:"─",boxhD:"╥",boxhU:"╨",boxhd:"┬",boxhu:"┴",boxminus:"⊟",boxplus:"⊞",boxtimes:"⊠",boxuL:"╛",boxuR:"╘",boxul:"┘",boxur:"└",boxv:"│",boxvH:"╪",boxvL:"╡",boxvR:"╞",boxvh:"┼",boxvl:"┤",boxvr:"├",bprime:"‵",breve:"˘",brvbar:"¦",bscr:"𝒷",bsemi:"⁏",bsim:"∽",bsime:"⋍",bsol:"\\",bsolb:"⧅",bsolhsub:"⟈",bull:"•",bullet:"•",bump:"≎",bumpE:"⪮",bumpe:"≏",bumpeq:"≏",cacute:"ć",cap:"∩",capand:"⩄",capbrcup:"⩉",capcap:"⩋",capcup:"⩇",capdot:"⩀",caps:"∩︀",caret:"⁁",caron:"ˇ",ccaps:"⩍",ccaron:"č",ccedil:"ç",ccirc:"ĉ",ccups:"⩌",ccupssm:"⩐",cdot:"ċ",cedil:"¸",cemptyv:"⦲",cent:"¢",centerdot:"·",cfr:"𝔠",chcy:"ч",check:"✓",checkmark:"✓",chi:"χ",cir:"○",cirE:"⧃",circ:"ˆ",circeq:"≗",circlearrowleft:"↺",circlearrowright:"↻",circledR:"®",circledS:"Ⓢ",circledast:"⊛",circledcirc:"⊚",circleddash:"⊝",cire:"≗",cirfnint:"⨐",cirmid:"⫯",cirscir:"⧂",clubs:"♣",clubsuit:"♣",colon:":",colone:"≔",coloneq:"≔",comma:",",commat:"@",comp:"∁",compfn:"∘",complement:"∁",complexes:"ℂ",cong:"≅",congdot:"⩭",conint:"∮",copf:"𝕔",coprod:"∐",copy:"©",copysr:"℗",crarr:"↵",cross:"✗",cscr:"𝒸",csub:"⫏",csube:"⫑",csup:"⫐",csupe:"⫒",ctdot:"⋯",cudarrl:"⤸",cudarrr:"⤵",cuepr:"⋞",cuesc:"⋟",cularr:"↶",cularrp:"⤽",cup:"∪",cupbrcap:"⩈",cupcap:"⩆",cupcup:"⩊",cupdot:"⊍",cupor:"⩅",cups:"∪︀",curarr:"↷",curarrm:"⤼",curlyeqprec:"⋞",curlyeqsucc:"⋟",curlyvee:"⋎",curlywedge:"⋏",curren:"¤",curvearrowleft:"↶",curvearrowright:"↷",cuvee:"⋎",cuwed:"⋏",cwconint:"∲",cwint:"∱",cylcty:"⌭",dArr:"⇓",dHar:"⥥",dagger:"†",daleth:"ℸ",darr:"↓",dash:"‐",dashv:"⊣",dbkarow:"⤏",dblac:"˝",dcaron:"ď",dcy:"д",dd:"ⅆ",ddagger:"‡",ddarr:"⇊",ddotseq:"⩷",deg:"°",delta:"δ",demptyv:"⦱",dfisht:"⥿",dfr:"𝔡",dharl:"⇃",dharr:"⇂",diam:"⋄",diamond:"⋄",diamondsuit:"♦",diams:"♦",die:"¨",digamma:"ϝ",disin:"⋲",div:"÷",divide:"÷",divideontimes:"⋇",divonx:"⋇",djcy:"ђ",dlcorn:"⌞",dlcrop:"⌍",dollar:"$",dopf:"𝕕",dot:"˙",doteq:"≐",doteqdot:"≑",dotminus:"∸",dotplus:"∔",dotsquare:"⊡",doublebarwedge:"⌆",downarrow:"↓",downdownarrows:"⇊",downharpoonleft:"⇃",downharpoonright:"⇂",drbkarow:"⤐",drcorn:"⌟",drcrop:"⌌",dscr:"𝒹",dscy:"ѕ",dsol:"⧶",dstrok:"đ",dtdot:"⋱",dtri:"▿",dtrif:"▾",duarr:"⇵",duhar:"⥯",dwangle:"⦦",dzcy:"џ",dzigrarr:"⟿",eDDot:"⩷",eDot:"≑",eacute:"é",easter:"⩮",ecaron:"ě",ecir:"≖",ecirc:"ê",ecolon:"≕",ecy:"э",edot:"ė",ee:"ⅇ",efDot:"≒",efr:"𝔢",eg:"⪚",egrave:"è",egs:"⪖",egsdot:"⪘",el:"⪙",elinters:"⏧",ell:"ℓ",els:"⪕",elsdot:"⪗",emacr:"ē",empty:"∅",emptyset:"∅",emptyv:"∅",emsp13:" ",emsp14:" ",emsp:" ",eng:"ŋ",ensp:" ",eogon:"ę",eopf:"𝕖",epar:"⋕",eparsl:"⧣",eplus:"⩱",epsi:"ε",epsilon:"ε",epsiv:"ϵ",eqcirc:"≖",eqcolon:"≕",eqsim:"≂",eqslantgtr:"⪖",eqslantless:"⪕",equals:"=",equest:"≟",equiv:"≡",equivDD:"⩸",eqvparsl:"⧥",erDot:"≓",erarr:"⥱",escr:"ℯ",esdot:"≐",esim:"≂",eta:"η",eth:"ð",euml:"ë",euro:"€",excl:"!",exist:"∃",expectation:"ℰ",exponentiale:"ⅇ",fallingdotseq:"≒",fcy:"ф",female:"♀",ffilig:"ffi",fflig:"ff",ffllig:"ffl",ffr:"𝔣",filig:"fi",fjlig:"fj",flat:"♭",fllig:"fl",fltns:"▱",fnof:"ƒ",fopf:"𝕗",forall:"∀",fork:"⋔",forkv:"⫙",fpartint:"⨍",frac12:"½",frac13:"⅓",frac14:"¼",frac15:"⅕",frac16:"⅙",frac18:"⅛",frac23:"⅔",frac25:"⅖",frac34:"¾",frac35:"⅗",frac38:"⅜",frac45:"⅘",frac56:"⅚",frac58:"⅝",frac78:"⅞",frasl:"⁄",frown:"⌢",fscr:"𝒻",gE:"≧",gEl:"⪌",gacute:"ǵ",gamma:"γ",gammad:"ϝ",gap:"⪆",gbreve:"ğ",gcirc:"ĝ",gcy:"г",gdot:"ġ",ge:"≥",gel:"⋛",geq:"≥",geqq:"≧",geqslant:"⩾",ges:"⩾",gescc:"⪩",gesdot:"⪀",gesdoto:"⪂",gesdotol:"⪄",gesl:"⋛︀",gesles:"⪔",gfr:"𝔤",gg:"≫",ggg:"⋙",gimel:"ℷ",gjcy:"ѓ",gl:"≷",glE:"⪒",gla:"⪥",glj:"⪤",gnE:"≩",gnap:"⪊",gnapprox:"⪊",gne:"⪈",gneq:"⪈",gneqq:"≩",gnsim:"⋧",gopf:"𝕘",grave:"`",gscr:"ℊ",gsim:"≳",gsime:"⪎",gsiml:"⪐",gt:">",gtcc:"⪧",gtcir:"⩺",gtdot:"⋗",gtlPar:"⦕",gtquest:"⩼",gtrapprox:"⪆",gtrarr:"⥸",gtrdot:"⋗",gtreqless:"⋛",gtreqqless:"⪌",gtrless:"≷",gtrsim:"≳",gvertneqq:"≩︀",gvnE:"≩︀",hArr:"⇔",hairsp:" ",half:"½",hamilt:"ℋ",hardcy:"ъ",harr:"↔",harrcir:"⥈",harrw:"↭",hbar:"ℏ",hcirc:"ĥ",hearts:"♥",heartsuit:"♥",hellip:"…",hercon:"⊹",hfr:"𝔥",hksearow:"⤥",hkswarow:"⤦",hoarr:"⇿",homtht:"∻",hookleftarrow:"↩",hookrightarrow:"↪",hopf:"𝕙",horbar:"―",hscr:"𝒽",hslash:"ℏ",hstrok:"ħ",hybull:"⁃",hyphen:"‐",iacute:"í",ic:"⁣",icirc:"î",icy:"и",iecy:"е",iexcl:"¡",iff:"⇔",ifr:"𝔦",igrave:"ì",ii:"ⅈ",iiiint:"⨌",iiint:"∭",iinfin:"⧜",iiota:"℩",ijlig:"ij",imacr:"ī",image:"ℑ",imagline:"ℐ",imagpart:"ℑ",imath:"ı",imof:"⊷",imped:"Ƶ",in:"∈",incare:"℅",infin:"∞",infintie:"⧝",inodot:"ı",int:"∫",intcal:"⊺",integers:"ℤ",intercal:"⊺",intlarhk:"⨗",intprod:"⨼",iocy:"ё",iogon:"į",iopf:"𝕚",iota:"ι",iprod:"⨼",iquest:"¿",iscr:"𝒾",isin:"∈",isinE:"⋹",isindot:"⋵",isins:"⋴",isinsv:"⋳",isinv:"∈",it:"⁢",itilde:"ĩ",iukcy:"і",iuml:"ï",jcirc:"ĵ",jcy:"й",jfr:"𝔧",jmath:"ȷ",jopf:"𝕛",jscr:"𝒿",jsercy:"ј",jukcy:"є",kappa:"κ",kappav:"ϰ",kcedil:"ķ",kcy:"к",kfr:"𝔨",kgreen:"ĸ",khcy:"х",kjcy:"ќ",kopf:"𝕜",kscr:"𝓀",lAarr:"⇚",lArr:"⇐",lAtail:"⤛",lBarr:"⤎",lE:"≦",lEg:"⪋",lHar:"⥢",lacute:"ĺ",laemptyv:"⦴",lagran:"ℒ",lambda:"λ",lang:"⟨",langd:"⦑",langle:"⟨",lap:"⪅",laquo:"«",larr:"←",larrb:"⇤",larrbfs:"⤟",larrfs:"⤝",larrhk:"↩",larrlp:"↫",larrpl:"⤹",larrsim:"⥳",larrtl:"↢",lat:"⪫",latail:"⤙",late:"⪭",lates:"⪭︀",lbarr:"⤌",lbbrk:"❲",lbrace:"{",lbrack:"[",lbrke:"⦋",lbrksld:"⦏",lbrkslu:"⦍",lcaron:"ľ",lcedil:"ļ",lceil:"⌈",lcub:"{",lcy:"л",ldca:"⤶",ldquo:"“",ldquor:"„",ldrdhar:"⥧",ldrushar:"⥋",ldsh:"↲",le:"≤",leftarrow:"←",leftarrowtail:"↢",leftharpoondown:"↽",leftharpoonup:"↼",leftleftarrows:"⇇",leftrightarrow:"↔",leftrightarrows:"⇆",leftrightharpoons:"⇋",leftrightsquigarrow:"↭",leftthreetimes:"⋋",leg:"⋚",leq:"≤",leqq:"≦",leqslant:"⩽",les:"⩽",lescc:"⪨",lesdot:"⩿",lesdoto:"⪁",lesdotor:"⪃",lesg:"⋚︀",lesges:"⪓",lessapprox:"⪅",lessdot:"⋖",lesseqgtr:"⋚",lesseqqgtr:"⪋",lessgtr:"≶",lesssim:"≲",lfisht:"⥼",lfloor:"⌊",lfr:"𝔩",lg:"≶",lgE:"⪑",lhard:"↽",lharu:"↼",lharul:"⥪",lhblk:"▄",ljcy:"љ",ll:"≪",llarr:"⇇",llcorner:"⌞",llhard:"⥫",lltri:"◺",lmidot:"ŀ",lmoust:"⎰",lmoustache:"⎰",lnE:"≨",lnap:"⪉",lnapprox:"⪉",lne:"⪇",lneq:"⪇",lneqq:"≨",lnsim:"⋦",loang:"⟬",loarr:"⇽",lobrk:"⟦",longleftarrow:"⟵",longleftrightarrow:"⟷",longmapsto:"⟼",longrightarrow:"⟶",looparrowleft:"↫",looparrowright:"↬",lopar:"⦅",lopf:"𝕝",loplus:"⨭",lotimes:"⨴",lowast:"∗",lowbar:"_",loz:"◊",lozenge:"◊",lozf:"⧫",lpar:"(",lparlt:"⦓",lrarr:"⇆",lrcorner:"⌟",lrhar:"⇋",lrhard:"⥭",lrm:"‎",lrtri:"⊿",lsaquo:"‹",lscr:"𝓁",lsh:"↰",lsim:"≲",lsime:"⪍",lsimg:"⪏",lsqb:"[",lsquo:"‘",lsquor:"‚",lstrok:"ł",lt:"<",ltcc:"⪦",ltcir:"⩹",ltdot:"⋖",lthree:"⋋",ltimes:"⋉",ltlarr:"⥶",ltquest:"⩻",ltrPar:"⦖",ltri:"◃",ltrie:"⊴",ltrif:"◂",lurdshar:"⥊",luruhar:"⥦",lvertneqq:"≨︀",lvnE:"≨︀",mDDot:"∺",macr:"¯",male:"♂",malt:"✠",maltese:"✠",map:"↦",mapsto:"↦",mapstodown:"↧",mapstoleft:"↤",mapstoup:"↥",marker:"▮",mcomma:"⨩",mcy:"м",mdash:"—",measuredangle:"∡",mfr:"𝔪",mho:"℧",micro:"µ",mid:"∣",midast:"*",midcir:"⫰",middot:"·",minus:"−",minusb:"⊟",minusd:"∸",minusdu:"⨪",mlcp:"⫛",mldr:"…",mnplus:"∓",models:"⊧",mopf:"𝕞",mp:"∓",mscr:"𝓂",mstpos:"∾",mu:"μ",multimap:"⊸",mumap:"⊸",nGg:"⋙̸",nGt:"≫⃒",nGtv:"≫̸",nLeftarrow:"⇍",nLeftrightarrow:"⇎",nLl:"⋘̸",nLt:"≪⃒",nLtv:"≪̸",nRightarrow:"⇏",nVDash:"⊯",nVdash:"⊮",nabla:"∇",nacute:"ń",nang:"∠⃒",nap:"≉",napE:"⩰̸",napid:"≋̸",napos:"ʼn",napprox:"≉",natur:"♮",natural:"♮",naturals:"ℕ",nbsp:" ",nbump:"≎̸",nbumpe:"≏̸",ncap:"⩃",ncaron:"ň",ncedil:"ņ",ncong:"≇",ncongdot:"⩭̸",ncup:"⩂",ncy:"н",ndash:"–",ne:"≠",neArr:"⇗",nearhk:"⤤",nearr:"↗",nearrow:"↗",nedot:"≐̸",nequiv:"≢",nesear:"⤨",nesim:"≂̸",nexist:"∄",nexists:"∄",nfr:"𝔫",ngE:"≧̸",nge:"≱",ngeq:"≱",ngeqq:"≧̸",ngeqslant:"⩾̸",nges:"⩾̸",ngsim:"≵",ngt:"≯",ngtr:"≯",nhArr:"⇎",nharr:"↮",nhpar:"⫲",ni:"∋",nis:"⋼",nisd:"⋺",niv:"∋",njcy:"њ",nlArr:"⇍",nlE:"≦̸",nlarr:"↚",nldr:"‥",nle:"≰",nleftarrow:"↚",nleftrightarrow:"↮",nleq:"≰",nleqq:"≦̸",nleqslant:"⩽̸",nles:"⩽̸",nless:"≮",nlsim:"≴",nlt:"≮",nltri:"⋪",nltrie:"⋬",nmid:"∤",nopf:"𝕟",not:"¬",notin:"∉",notinE:"⋹̸",notindot:"⋵̸",notinva:"∉",notinvb:"⋷",notinvc:"⋶",notni:"∌",notniva:"∌",notnivb:"⋾",notnivc:"⋽",npar:"∦",nparallel:"∦",nparsl:"⫽⃥",npart:"∂̸",npolint:"⨔",npr:"⊀",nprcue:"⋠",npre:"⪯̸",nprec:"⊀",npreceq:"⪯̸",nrArr:"⇏",nrarr:"↛",nrarrc:"⤳̸",nrarrw:"↝̸",nrightarrow:"↛",nrtri:"⋫",nrtrie:"⋭",nsc:"⊁",nsccue:"⋡",nsce:"⪰̸",nscr:"𝓃",nshortmid:"∤",nshortparallel:"∦",nsim:"≁",nsime:"≄",nsimeq:"≄",nsmid:"∤",nspar:"∦",nsqsube:"⋢",nsqsupe:"⋣",nsub:"⊄",nsubE:"⫅̸",nsube:"⊈",nsubset:"⊂⃒",nsubseteq:"⊈",nsubseteqq:"⫅̸",nsucc:"⊁",nsucceq:"⪰̸",nsup:"⊅",nsupE:"⫆̸",nsupe:"⊉",nsupset:"⊃⃒",nsupseteq:"⊉",nsupseteqq:"⫆̸",ntgl:"≹",ntilde:"ñ",ntlg:"≸",ntriangleleft:"⋪",ntrianglelefteq:"⋬",ntriangleright:"⋫",ntrianglerighteq:"⋭",nu:"ν",num:"#",numero:"№",numsp:" ",nvDash:"⊭",nvHarr:"⤄",nvap:"≍⃒",nvdash:"⊬",nvge:"≥⃒",nvgt:">⃒",nvinfin:"⧞",nvlArr:"⤂",nvle:"≤⃒",nvlt:"<⃒",nvltrie:"⊴⃒",nvrArr:"⤃",nvrtrie:"⊵⃒",nvsim:"∼⃒",nwArr:"⇖",nwarhk:"⤣",nwarr:"↖",nwarrow:"↖",nwnear:"⤧",oS:"Ⓢ",oacute:"ó",oast:"⊛",ocir:"⊚",ocirc:"ô",ocy:"о",odash:"⊝",odblac:"ő",odiv:"⨸",odot:"⊙",odsold:"⦼",oelig:"œ",ofcir:"⦿",ofr:"𝔬",ogon:"˛",ograve:"ò",ogt:"⧁",ohbar:"⦵",ohm:"Ω",oint:"∮",olarr:"↺",olcir:"⦾",olcross:"⦻",oline:"‾",olt:"⧀",omacr:"ō",omega:"ω",omicron:"ο",omid:"⦶",ominus:"⊖",oopf:"𝕠",opar:"⦷",operp:"⦹",oplus:"⊕",or:"∨",orarr:"↻",ord:"⩝",order:"ℴ",orderof:"ℴ",ordf:"ª",ordm:"º",origof:"⊶",oror:"⩖",orslope:"⩗",orv:"⩛",oscr:"ℴ",oslash:"ø",osol:"⊘",otilde:"õ",otimes:"⊗",otimesas:"⨶",ouml:"ö",ovbar:"⌽",par:"∥",para:"¶",parallel:"∥",parsim:"⫳",parsl:"⫽",part:"∂",pcy:"п",percnt:"%",period:".",permil:"‰",perp:"⊥",pertenk:"‱",pfr:"𝔭",phi:"φ",phiv:"ϕ",phmmat:"ℳ",phone:"☎",pi:"π",pitchfork:"⋔",piv:"ϖ",planck:"ℏ",planckh:"ℎ",plankv:"ℏ",plus:"+",plusacir:"⨣",plusb:"⊞",pluscir:"⨢",plusdo:"∔",plusdu:"⨥",pluse:"⩲",plusmn:"±",plussim:"⨦",plustwo:"⨧",pm:"±",pointint:"⨕",popf:"𝕡",pound:"£",pr:"≺",prE:"⪳",prap:"⪷",prcue:"≼",pre:"⪯",prec:"≺",precapprox:"⪷",preccurlyeq:"≼",preceq:"⪯",precnapprox:"⪹",precneqq:"⪵",precnsim:"⋨",precsim:"≾",prime:"′",primes:"ℙ",prnE:"⪵",prnap:"⪹",prnsim:"⋨",prod:"∏",profalar:"⌮",profline:"⌒",profsurf:"⌓",prop:"∝",propto:"∝",prsim:"≾",prurel:"⊰",pscr:"𝓅",psi:"ψ",puncsp:" ",qfr:"𝔮",qint:"⨌",qopf:"𝕢",qprime:"⁗",qscr:"𝓆",quaternions:"ℍ",quatint:"⨖",quest:"?",questeq:"≟",quot:'"',rAarr:"⇛",rArr:"⇒",rAtail:"⤜",rBarr:"⤏",rHar:"⥤",race:"∽̱",racute:"ŕ",radic:"√",raemptyv:"⦳",rang:"⟩",rangd:"⦒",range:"⦥",rangle:"⟩",raquo:"»",rarr:"→",rarrap:"⥵",rarrb:"⇥",rarrbfs:"⤠",rarrc:"⤳",rarrfs:"⤞",rarrhk:"↪",rarrlp:"↬",rarrpl:"⥅",rarrsim:"⥴",rarrtl:"↣",rarrw:"↝",ratail:"⤚",ratio:"∶",rationals:"ℚ",rbarr:"⤍",rbbrk:"❳",rbrace:"}",rbrack:"]",rbrke:"⦌",rbrksld:"⦎",rbrkslu:"⦐",rcaron:"ř",rcedil:"ŗ",rceil:"⌉",rcub:"}",rcy:"р",rdca:"⤷",rdldhar:"⥩",rdquo:"”",rdquor:"”",rdsh:"↳",real:"ℜ",realine:"ℛ",realpart:"ℜ",reals:"ℝ",rect:"▭",reg:"®",rfisht:"⥽",rfloor:"⌋",rfr:"𝔯",rhard:"⇁",rharu:"⇀",rharul:"⥬",rho:"ρ",rhov:"ϱ",rightarrow:"→",rightarrowtail:"↣",rightharpoondown:"⇁",rightharpoonup:"⇀",rightleftarrows:"⇄",rightleftharpoons:"⇌",rightrightarrows:"⇉",rightsquigarrow:"↝",rightthreetimes:"⋌",ring:"˚",risingdotseq:"≓",rlarr:"⇄",rlhar:"⇌",rlm:"‏",rmoust:"⎱",rmoustache:"⎱",rnmid:"⫮",roang:"⟭",roarr:"⇾",robrk:"⟧",ropar:"⦆",ropf:"𝕣",roplus:"⨮",rotimes:"⨵",rpar:")",rpargt:"⦔",rppolint:"⨒",rrarr:"⇉",rsaquo:"›",rscr:"𝓇",rsh:"↱",rsqb:"]",rsquo:"’",rsquor:"’",rthree:"⋌",rtimes:"⋊",rtri:"▹",rtrie:"⊵",rtrif:"▸",rtriltri:"⧎",ruluhar:"⥨",rx:"℞",sacute:"ś",sbquo:"‚",sc:"≻",scE:"⪴",scap:"⪸",scaron:"š",sccue:"≽",sce:"⪰",scedil:"ş",scirc:"ŝ",scnE:"⪶",scnap:"⪺",scnsim:"⋩",scpolint:"⨓",scsim:"≿",scy:"с",sdot:"⋅",sdotb:"⊡",sdote:"⩦",seArr:"⇘",searhk:"⤥",searr:"↘",searrow:"↘",sect:"§",semi:";",seswar:"⤩",setminus:"∖",setmn:"∖",sext:"✶",sfr:"𝔰",sfrown:"⌢",sharp:"♯",shchcy:"щ",shcy:"ш",shortmid:"∣",shortparallel:"∥",shy:"­",sigma:"σ",sigmaf:"ς",sigmav:"ς",sim:"∼",simdot:"⩪",sime:"≃",simeq:"≃",simg:"⪞",simgE:"⪠",siml:"⪝",simlE:"⪟",simne:"≆",simplus:"⨤",simrarr:"⥲",slarr:"←",smallsetminus:"∖",smashp:"⨳",smeparsl:"⧤",smid:"∣",smile:"⌣",smt:"⪪",smte:"⪬",smtes:"⪬︀",softcy:"ь",sol:"/",solb:"⧄",solbar:"⌿",sopf:"𝕤",spades:"♠",spadesuit:"♠",spar:"∥",sqcap:"⊓",sqcaps:"⊓︀",sqcup:"⊔",sqcups:"⊔︀",sqsub:"⊏",sqsube:"⊑",sqsubset:"⊏",sqsubseteq:"⊑",sqsup:"⊐",sqsupe:"⊒",sqsupset:"⊐",sqsupseteq:"⊒",squ:"□",square:"□",squarf:"▪",squf:"▪",srarr:"→",sscr:"𝓈",ssetmn:"∖",ssmile:"⌣",sstarf:"⋆",star:"☆",starf:"★",straightepsilon:"ϵ",straightphi:"ϕ",strns:"¯",sub:"⊂",subE:"⫅",subdot:"⪽",sube:"⊆",subedot:"⫃",submult:"⫁",subnE:"⫋",subne:"⊊",subplus:"⪿",subrarr:"⥹",subset:"⊂",subseteq:"⊆",subseteqq:"⫅",subsetneq:"⊊",subsetneqq:"⫋",subsim:"⫇",subsub:"⫕",subsup:"⫓",succ:"≻",succapprox:"⪸",succcurlyeq:"≽",succeq:"⪰",succnapprox:"⪺",succneqq:"⪶",succnsim:"⋩",succsim:"≿",sum:"∑",sung:"♪",sup1:"¹",sup2:"²",sup3:"³",sup:"⊃",supE:"⫆",supdot:"⪾",supdsub:"⫘",supe:"⊇",supedot:"⫄",suphsol:"⟉",suphsub:"⫗",suplarr:"⥻",supmult:"⫂",supnE:"⫌",supne:"⊋",supplus:"⫀",supset:"⊃",supseteq:"⊇",supseteqq:"⫆",supsetneq:"⊋",supsetneqq:"⫌",supsim:"⫈",supsub:"⫔",supsup:"⫖",swArr:"⇙",swarhk:"⤦",swarr:"↙",swarrow:"↙",swnwar:"⤪",szlig:"ß",target:"⌖",tau:"τ",tbrk:"⎴",tcaron:"ť",tcedil:"ţ",tcy:"т",tdot:"⃛",telrec:"⌕",tfr:"𝔱",there4:"∴",therefore:"∴",theta:"θ",thetasym:"ϑ",thetav:"ϑ",thickapprox:"≈",thicksim:"∼",thinsp:" ",thkap:"≈",thksim:"∼",thorn:"þ",tilde:"˜",times:"×",timesb:"⊠",timesbar:"⨱",timesd:"⨰",tint:"∭",toea:"⤨",top:"⊤",topbot:"⌶",topcir:"⫱",topf:"𝕥",topfork:"⫚",tosa:"⤩",tprime:"‴",trade:"™",triangle:"▵",triangledown:"▿",triangleleft:"◃",trianglelefteq:"⊴",triangleq:"≜",triangleright:"▹",trianglerighteq:"⊵",tridot:"◬",trie:"≜",triminus:"⨺",triplus:"⨹",trisb:"⧍",tritime:"⨻",trpezium:"⏢",tscr:"𝓉",tscy:"ц",tshcy:"ћ",tstrok:"ŧ",twixt:"≬",twoheadleftarrow:"↞",twoheadrightarrow:"↠",uArr:"⇑",uHar:"⥣",uacute:"ú",uarr:"↑",ubrcy:"ў",ubreve:"ŭ",ucirc:"û",ucy:"у",udarr:"⇅",udblac:"ű",udhar:"⥮",ufisht:"⥾",ufr:"𝔲",ugrave:"ù",uharl:"↿",uharr:"↾",uhblk:"▀",ulcorn:"⌜",ulcorner:"⌜",ulcrop:"⌏",ultri:"◸",umacr:"ū",uml:"¨",uogon:"ų",uopf:"𝕦",uparrow:"↑",updownarrow:"↕",upharpoonleft:"↿",upharpoonright:"↾",uplus:"⊎",upsi:"υ",upsih:"ϒ",upsilon:"υ",upuparrows:"⇈",urcorn:"⌝",urcorner:"⌝",urcrop:"⌎",uring:"ů",urtri:"◹",uscr:"𝓊",utdot:"⋰",utilde:"ũ",utri:"▵",utrif:"▴",uuarr:"⇈",uuml:"ü",uwangle:"⦧",vArr:"⇕",vBar:"⫨",vBarv:"⫩",vDash:"⊨",vangrt:"⦜",varepsilon:"ϵ",varkappa:"ϰ",varnothing:"∅",varphi:"ϕ",varpi:"ϖ",varpropto:"∝",varr:"↕",varrho:"ϱ",varsigma:"ς",varsubsetneq:"⊊︀",varsubsetneqq:"⫋︀",varsupsetneq:"⊋︀",varsupsetneqq:"⫌︀",vartheta:"ϑ",vartriangleleft:"⊲",vartriangleright:"⊳",vcy:"в",vdash:"⊢",vee:"∨",veebar:"⊻",veeeq:"≚",vellip:"⋮",verbar:"|",vert:"|",vfr:"𝔳",vltri:"⊲",vnsub:"⊂⃒",vnsup:"⊃⃒",vopf:"𝕧",vprop:"∝",vrtri:"⊳",vscr:"𝓋",vsubnE:"⫋︀",vsubne:"⊊︀",vsupnE:"⫌︀",vsupne:"⊋︀",vzigzag:"⦚",wcirc:"ŵ",wedbar:"⩟",wedge:"∧",wedgeq:"≙",weierp:"℘",wfr:"𝔴",wopf:"𝕨",wp:"℘",wr:"≀",wreath:"≀",wscr:"𝓌",xcap:"⋂",xcirc:"◯",xcup:"⋃",xdtri:"▽",xfr:"𝔵",xhArr:"⟺",xharr:"⟷",xi:"ξ",xlArr:"⟸",xlarr:"⟵",xmap:"⟼",xnis:"⋻",xodot:"⨀",xopf:"𝕩",xoplus:"⨁",xotime:"⨂",xrArr:"⟹",xrarr:"⟶",xscr:"𝓍",xsqcup:"⨆",xuplus:"⨄",xutri:"△",xvee:"⋁",xwedge:"⋀",yacute:"ý",yacy:"я",ycirc:"ŷ",ycy:"ы",yen:"¥",yfr:"𝔶",yicy:"ї",yopf:"𝕪",yscr:"𝓎",yucy:"ю",yuml:"ÿ",zacute:"ź",zcaron:"ž",zcy:"з",zdot:"ż",zeetrf:"ℨ",zeta:"ζ",zfr:"𝔷",zhcy:"ж",zigrarr:"⇝",zopf:"𝕫",zscr:"𝓏",zwj:"‍",zwnj:"‌"},re={0:65533,128:8364,130:8218,131:402,132:8222,133:8230,134:8224,135:8225,136:710,137:8240,138:352,139:8249,140:338,142:381,145:8216,146:8217,147:8220,148:8221,149:8226,150:8211,151:8212,152:732,153:8482,154:353,155:8250,156:339,158:382,159:376};function ne(e){return e.replace(/&(?:[a-zA-Z]+|#[xX][\da-fA-F]+|#\d+);/g,(e=>{if("#"===e.charAt(1)){const t=e.charAt(2);return function(e){if(e>=55296&&e<=57343||e>1114111)return"�";return String.fromCodePoint(y(re,e)??e)}("X"===t||"x"===t?parseInt(e.slice(3),16):parseInt(e.slice(2),10))}return y(te,e.slice(1,-1))??e}))}function oe(e,t){return e.startIndex=e.tokenIndex=e.index,e.startColumn=e.tokenColumn=e.column,e.startLine=e.tokenLine=e.line,e.setToken(8192&n[e.currentChar]?function(e){const t=e.currentChar;let r=c(e);const n=e.index;for(;r!==t;)e.index>=e.end&&e.report(16),r=c(e);r!==t&&e.report(16);e.tokenValue=e.source.slice(n,e.index),c(e),e.options.raw&&(e.tokenRaw=e.source.slice(e.tokenIndex,e.index));return 134283267}(e):F(e,t,0)),e.getToken()}function ae(e){if(e.startIndex=e.tokenIndex=e.index,e.startColumn=e.tokenColumn=e.column,e.startLine=e.tokenLine=e.line,e.index>=e.end)return void e.setToken(1048576);if(60===e.currentChar)return c(e),void e.setToken(8456256);if(123===e.currentChar)return c(e),void e.setToken(2162700);let t=0;for(;e.indexe.declareUnboundVariable(t)))):(c&&e.report(172),r&&(n.forEach((t=>e.declareUnboundVariable(t))),a.forEach((t=>e.addBindingToExports(t))))),M(e,32|t);break}case 132:case 86094:a=Tt(e,t,r,void 0,2);break;case 86104:a=nt(e,t,r,void 0,4,1,2,0,e.tokenStart);break;case 241737:a=we(e,t,r,void 0,8,64);break;case 86090:a=we(e,t,r,void 0,16,64);break;case 86088:a=Se(e,t,r,void 0,64);break;case 209005:{const{tokenStart:n}=e;if(j(e,t),!(1&e.flags)&&86104===e.getToken()){a=nt(e,t,r,void 0,4,1,2,1,n);break}}default:e.report(30,x[255&e.getToken()])}const c={type:"ExportNamedDeclaration",declaration:a,specifiers:o,source:i,attributes:s};return e.finishNode(c,n)}(e,t,r);break;case 86106:n=function(e,t,r){const n=e.tokenStart;j(e,t);let o=null;const{tokenStart:a}=e;let i=[];if(134283267===e.getToken())o=rt(e,t);else{if(143360&e.getToken()){const n=qe(e,t,r);if(i=[e.finishNode({type:"ImportDefaultSpecifier",local:n},a)],z(e,t,18))switch(e.getToken()){case 8391476:i.push(Ee(e,t,r));break;case 2162700:Ne(e,t,r,i);break;default:e.report(107)}}else switch(e.getToken()){case 8391476:i=[Ee(e,t,r)];break;case 2162700:Ne(e,t,r,i);break;case 67174411:return Ae(e,t,void 0,n);case 67108877:return Le(e,t,n);default:e.report(30,x[255&e.getToken()])}o=function(e,t){X(e,t,209011),134283267!==e.getToken()&&e.report(105,"Import");return rt(e,t)}(e,t)}const s=ze(e,t),c={type:"ImportDeclaration",specifiers:i,source:o,attributes:s};return M(e,32|t),e.finishNode(c,n)}(e,t,r);break;default:n=ge(e,t,r,void 0,4,{})}return e.leadingDecorators?.decorators.length&&e.report(170),n}function ge(e,t,r,n,o,a){const i=e.tokenStart;switch(e.getToken()){case 86104:return nt(e,t,r,n,o,1,0,0,i);case 132:case 86094:return Tt(e,t,r,n,0);case 86090:return we(e,t,r,n,16,0);case 241737:return function(e,t,r,n,o){const{tokenValue:a,tokenStart:i}=e,s=e.getToken();let c=tt(e,t);if(2240512&e.getToken()){const o=ve(e,t,r,n,8,0);return M(e,32|t),e.finishNode({type:"VariableDeclaration",kind:"let",declarations:o},i)}e.assignable=1,1&t&&e.report(85);if(21===e.getToken())return me(e,t,r,n,o,{},a,c,s,0,i);if(10===e.getToken()){let r;e.options.lexical&&(r=le(e,t,a)),e.flags=128^(128|e.flags),c=ft(e,t,r,n,[c],0,i)}else c=je(e,t,n,c,0,0,i),c=Re(e,t,n,0,0,i,c);18===e.getToken()&&(c=Ve(e,t,n,0,i,c));return he(e,t,c,i)}(e,t,r,n,o);case 20564:e.report(103,"export");case 86106:switch(j(e,t),e.getToken()){case 67174411:return Ae(e,t,n,i);case 67108877:return Le(e,t,i);default:e.report(103,"import")}case 209005:return be(e,t,r,n,o,a,1);default:return fe(e,t,r,n,o,a,1)}}function fe(e,t,r,n,o,a,i){switch(e.getToken()){case 86088:return Se(e,t,r,n,0);case 20572:return function(e,t,r){4096&t||e.report(92);const n=e.tokenStart;j(e,32|t);const o=1&e.flags||1048576&e.getToken()?null:De(e,t,r,0,1,e.tokenStart);return M(e,32|t),e.finishNode({type:"ReturnStatement",argument:o},n)}(e,t,n);case 20569:return function(e,t,r,n,o){const a=e.tokenStart;j(e,t),X(e,32|t,67174411),e.assignable=1;const i=De(e,t,n,0,1,e.tokenStart);X(e,32|t,16);const s=ye(e,t,r,n,o);let c=null;20563===e.getToken()&&(j(e,32|t),c=ye(e,t,r,n,o));return e.finishNode({type:"IfStatement",test:i,consequent:s,alternate:c},a)}(e,t,r,n,a);case 20567:return function(e,t,r,n,o){const a=e.tokenStart;j(e,t);const i=((2048&t)>0||(2&t)>0&&(8&t)>0)&&z(e,t,209006);X(e,32|t,67174411),r=r?.createChildScope(1);let s,c=null,l=null,u=0,p=null,d=86088===e.getToken()||241737===e.getToken()||86090===e.getToken();const{tokenStart:g}=e,f=e.getToken();if(d)241737===f?(p=tt(e,t),2240512&e.getToken()?(8673330===e.getToken()?1&t&&e.report(67):p=e.finishNode({type:"VariableDeclaration",kind:"let",declarations:ve(e,131072|t,r,n,8,32)},g),e.assignable=1):1&t?e.report(67):(d=!1,e.assignable=1,p=je(e,t,n,p,0,0,g),471156===e.getToken()&&e.report(115))):(j(e,t),p=e.finishNode(86088===f?{type:"VariableDeclaration",kind:"var",declarations:ve(e,131072|t,r,n,4,32)}:{type:"VariableDeclaration",kind:"const",declarations:ve(e,131072|t,r,n,16,32)},g),e.assignable=1);else if(1074790417===f)i&&e.report(82);else if(2097152&~f)p=Ge(e,131072|t,n,1,0,1);else{const r=e.tokenStart;p=2162700===f?lt(e,t,void 0,n,1,0,0,2,32):at(e,t,void 0,n,1,0,0,2,32),u=e.destructible,64&u&&e.report(63),e.assignable=16&u?2:1,p=je(e,131072|t,n,p,0,0,r)}if(!(262144&~e.getToken())){if(471156===e.getToken()){2&e.assignable&&e.report(80,i?"await":"of"),_(e,p),j(e,32|t),s=Ie(e,t,n,1,0,e.tokenStart),X(e,32|t,16);const c=xe(e,t,r,n,o);return e.finishNode({type:"ForOfStatement",left:p,right:s,body:c,await:i},a)}2&e.assignable&&e.report(80,"in"),_(e,p),j(e,32|t),i&&e.report(82),s=De(e,t,n,0,1,e.tokenStart),X(e,32|t,16);const c=xe(e,t,r,n,o);return e.finishNode({type:"ForInStatement",body:c,left:p,right:s},a)}i&&e.report(82);d||(8&u&&1077936155!==e.getToken()&&e.report(80,"loop"),p=Re(e,131072|t,n,0,0,g,p));18===e.getToken()&&(p=Ve(e,t,n,0,g,p));X(e,32|t,1074790417),1074790417!==e.getToken()&&(c=De(e,t,n,0,1,e.tokenStart));X(e,32|t,1074790417),16!==e.getToken()&&(l=De(e,t,n,0,1,e.tokenStart));X(e,32|t,16);const k=xe(e,t,r,n,o);return e.finishNode({type:"ForStatement",init:p,test:c,update:l,body:k},a)}(e,t,r,n,a);case 20562:return function(e,t,r,n,o){const a=e.tokenStart;j(e,32|t);const i=xe(e,t,r,n,o);X(e,t,20578),X(e,32|t,67174411);const s=De(e,t,n,0,1,e.tokenStart);return X(e,32|t,16),z(e,32|t,1074790417),e.finishNode({type:"DoWhileStatement",body:i,test:s},a)}(e,t,r,n,a);case 20578:return function(e,t,r,n,o){const a=e.tokenStart;j(e,t),X(e,32|t,67174411);const i=De(e,t,n,0,1,e.tokenStart);X(e,32|t,16);const s=xe(e,t,r,n,o);return e.finishNode({type:"WhileStatement",test:i,body:s},a)}(e,t,r,n,a);case 86110:return function(e,t,r,n,o){const a=e.tokenStart;j(e,t),X(e,32|t,67174411);const i=De(e,t,n,0,1,e.tokenStart);X(e,t,16),X(e,t,2162700);const s=[];let c=0;r=r?.createChildScope(8);for(;1074790415!==e.getToken();){const{tokenStart:a}=e;let i=null;const l=[];for(z(e,32|t,20556)?i=De(e,t,n,0,1,e.tokenStart):(X(e,32|t,20561),c&&e.report(89),c=1),X(e,32|t,21);20556!==e.getToken()&&1074790415!==e.getToken()&&20561!==e.getToken();)l.push(ge(e,4|t,r,n,2,{$:o}));s.push(e.finishNode({type:"SwitchCase",test:i,consequent:l},a))}return X(e,32|t,1074790415),e.finishNode({type:"SwitchStatement",discriminant:i,cases:s},a)}(e,t,r,n,a);case 1074790417:return function(e,t){const r=e.tokenStart;return j(e,32|t),e.finishNode({type:"EmptyStatement"},r)}(e,t);case 2162700:return ke(e,t,r?.createChildScope(),n,a,e.tokenStart);case 86112:return function(e,t,r){const n=e.tokenStart;j(e,32|t),1&e.flags&&e.report(90);const o=De(e,t,r,0,1,e.tokenStart);return M(e,32|t),e.finishNode({type:"ThrowStatement",argument:o},n)}(e,t,n);case 20555:return function(e,t,r){const n=e.tokenStart;j(e,32|t);let o=null;if(!(1&e.flags)&&143360&e.getToken()){const{tokenValue:n}=e;o=tt(e,32|t),Z(e,r,n,0)||e.report(138,n)}else 132&t||e.report(69);return M(e,32|t),e.finishNode({type:"BreakStatement",label:o},n)}(e,t,a);case 20559:return function(e,t,r){128&t||e.report(68);const n=e.tokenStart;j(e,t);let o=null;if(!(1&e.flags)&&143360&e.getToken()){const{tokenValue:n}=e;o=tt(e,32|t),Z(e,r,n,1)||e.report(138,n)}return M(e,32|t),e.finishNode({type:"ContinueStatement",label:o},n)}(e,t,a);case 20577:return function(e,t,r,n,o){const a=e.tokenStart;j(e,32|t);const i=r?.createChildScope(16),s=ke(e,t,i,n,{$:o}),{tokenStart:c}=e,l=z(e,32|t,20557)?function(e,t,r,n,o,a){let i=null,s=r;z(e,t,67174411)&&(r=r?.createChildScope(4),i=qt(e,t,r,n,2097152&~e.getToken()?512:256,0),18===e.getToken()?e.report(86):1077936155===e.getToken()&&e.report(87),X(e,32|t,16));s=r?.createChildScope(32);const c=ke(e,t,s,n,{$:o});return e.finishNode({type:"CatchClause",param:i,body:c},a)}(e,t,r,n,o,c):null;let u=null;if(20566===e.getToken()){j(e,32|t);const a=r?.createChildScope(4);u=ke(e,t,a,n,{$:o})}l||u||e.report(88);return e.finishNode({type:"TryStatement",block:s,handler:l,finalizer:u},a)}(e,t,r,n,a);case 20579:return function(e,t,r,n,o){const a=e.tokenStart;j(e,t),1&t&&e.report(91);X(e,32|t,67174411);const i=De(e,t,n,0,1,e.tokenStart);X(e,32|t,16);const s=fe(e,t,r,n,2,o,0);return e.finishNode({type:"WithStatement",object:i,body:s},a)}(e,t,r,n,a);case 20560:return function(e,t){const r=e.tokenStart;return j(e,32|t),M(e,32|t),e.finishNode({type:"DebuggerStatement"},r)}(e,t);case 209005:return be(e,t,r,n,o,a,0);case 20557:e.report(162);case 20566:e.report(163);case 86104:e.report(1&t?76:e.options.webcompat?77:78);case 86094:e.report(79);default:return function(e,t,r,n,o,a,i){const{tokenValue:s,tokenStart:c}=e,l=e.getToken();let u;if(241737===l)u=tt(e,t),1&t&&e.report(85),69271571===e.getToken()&&e.report(84);else u=Me(e,t,n,2,0,1,0,1,e.tokenStart);if(143360&l&&21===e.getToken())return me(e,t,r,n,o,a,s,u,l,i,c);u=je(e,t,n,u,0,0,c),u=Re(e,t,n,0,0,c,u),18===e.getToken()&&(u=Ve(e,t,n,0,c,u));return he(e,t,u,c)}(e,t,r,n,o,a,i)}}function ke(e,t,r,n,o,a=e.tokenStart,i="BlockStatement"){const s=[];for(X(e,32|t,2162700);1074790415!==e.getToken();)s.push(ge(e,t,r,n,2,{$:o}));return X(e,32|t,1074790415),e.finishNode({type:i,body:s},a)}function he(e,t,r,n){return M(e,32|t),e.finishNode({type:"ExpressionStatement",expression:r},n)}function me(e,t,r,n,o,a,i,s,c,l,u){$(e,t,0,c,1),function(e,t,r){let n=t;for(;n;)n["$"+r]&&e.report(136,r),n=n.$;t["$"+r]=1}(e,a,i),j(e,32|t);const p=!l||1&t||!e.options.webcompat||86104!==e.getToken()?fe(e,t,r,n,o,a,l):nt(e,t,r?.createChildScope(),n,o,0,0,0,e.tokenStart);return e.finishNode({type:"LabeledStatement",label:s,body:p},u)}function be(e,t,r,n,o,a,i){const{tokenValue:s,tokenStart:c}=e,l=e.getToken();let u=tt(e,t);if(21===e.getToken())return me(e,t,r,n,o,a,s,u,l,1,c);const p=1&e.flags;if(!p){if(86104===e.getToken())return i||e.report(123),nt(e,t,r,n,o,1,0,1,c);if(Q(t,e.getToken()))return u=mt(e,t,n,1,c),18===e.getToken()&&(u=Ve(e,t,n,0,c,u)),he(e,t,u,c)}return 67174411===e.getToken()?u=bt(e,t,n,u,1,1,0,p,c):(10===e.getToken()&&(ee(e,t,l),36864&~l||(e.flags|=256),u=dt(e,2048|t,n,e.tokenValue,u,0,1,0,c)),e.assignable=1),u=je(e,t,n,u,0,0,c),u=Re(e,t,n,0,0,c,u),e.assignable=1,18===e.getToken()&&(u=Ve(e,t,n,0,c,u)),he(e,t,u,c)}function Te(e,t,r,n,o){const a=e.startIndex;1074790417!==n&&(e.assignable=2,r=je(e,t,void 0,r,0,0,o),1074790417!==e.getToken()&&(r=Re(e,t,void 0,0,0,o,r),18===e.getToken()&&(r=Ve(e,t,void 0,0,o,r))),M(e,32|t));const i={type:"ExpressionStatement",expression:r};return"Literal"===r.type&&"string"==typeof r.value&&(i.directive=e.source.slice(o.index+1,a-1)),e.finishNode(i,o)}function ye(e,t,r,n,o){const{tokenStart:a}=e;return 1&t||!e.options.webcompat||86104!==e.getToken()?fe(e,t,r,n,0,{$:o},0):nt(e,t,r?.createChildScope(),n,0,0,0,0,a)}function xe(e,t,r,n,o){return fe(e,131072^(131072|t)|128,r,n,0,{loop:1,$:o},0)}function we(e,t,r,n,o,a){const i=e.tokenStart;j(e,t);const s=ve(e,t,r,n,o,a);return M(e,32|t),e.finishNode({type:"VariableDeclaration",kind:8&o?"let":"const",declarations:s},i)}function Se(e,t,r,n,o){const a=e.tokenStart;j(e,t);const i=ve(e,t,r,n,4,o);return M(e,32|t),e.finishNode({type:"VariableDeclaration",kind:"var",declarations:i},a)}function ve(e,t,r,n,o,a){let i=1;const s=[Ce(e,t,r,n,o,a)];for(;z(e,t,18);)i++,s.push(Ce(e,t,r,n,o,a));return i>1&&32&a&&262144&e.getToken()&&e.report(61,x[255&e.getToken()]),s}function Ce(e,t,r,n,o,a){const{tokenStart:i}=e,s=e.getToken();let c=null;const l=qt(e,t,r,n,o,a);if(1077936155===e.getToken()){if(j(e,32|t),c=Ie(e,t,n,1,0,e.tokenStart),(32&a||!(2097152&s))&&(471156===e.getToken()||8673330===e.getToken()&&(2097152&s||!(4&o)||1&t)))throw new T(i,e.currentLocation,60,471156===e.getToken()?"of":"in")}else(16&o||(2097152&s)>0)&&262144&~e.getToken()&&e.report(59,16&o?"const":"destructuring");return e.finishNode({type:"VariableDeclarator",id:l,init:c},i)}function qe(e,t,r){return Q(t,e.getToken())||e.report(118),537079808&~e.getToken()||e.report(119),r?.addBlockName(t,e.tokenValue,8,0),tt(e,t)}function Ee(e,t,r){const{tokenStart:n}=e;if(j(e,t),X(e,t,77932),!(134217728&~e.getToken()))throw new T(n,e.currentLocation,30,x[255&e.getToken()]);return e.finishNode({type:"ImportNamespaceSpecifier",local:qe(e,t,r)},n)}function Ne(e,t,r,n){for(j(e,t);143360&e.getToken()||134283267===e.getToken();){let{tokenValue:o,tokenStart:a}=e;const i=e.getToken(),s=$e(e,t);let c;z(e,t,77932)?(134217728&~e.getToken()&&18!==e.getToken()?$(e,t,16,e.getToken(),0):e.report(106),o=e.tokenValue,c=tt(e,t)):"Identifier"===s.type?($(e,t,16,i,0),c=e.cloneIdentifier(s)):e.report(25,x[108]),r?.addBlockName(t,o,8,0),n.push(e.finishNode({type:"ImportSpecifier",local:c,imported:s},a)),1074790415!==e.getToken()&&X(e,t,18)}return X(e,t,1074790415),n}function Le(e,t,r){let n=He(e,t,e.finishNode({type:"Identifier",name:"import"},r),r);return n=je(e,t,void 0,n,0,0,r),n=Re(e,t,void 0,0,0,r,n),18===e.getToken()&&(n=Ve(e,t,void 0,0,r,n)),he(e,t,n,r)}function Ae(e,t,r,n){let o=Je(e,t,r,0,n);return o=je(e,t,r,o,0,0,n),18===e.getToken()&&(o=Ve(e,t,r,0,n,o)),he(e,t,o,n)}function Ie(e,t,r,n,o,a){let i=Me(e,t,r,2,0,n,o,1,a);return i=je(e,t,r,i,o,0,a),Re(e,t,r,o,0,a,i)}function Ve(e,t,r,n,o,a){const i=[a];for(;z(e,32|t,18);)i.push(Ie(e,t,r,1,n,e.tokenStart));return e.finishNode({type:"SequenceExpression",expressions:i},o)}function De(e,t,r,n,o,a){const i=Ie(e,t,r,o,n,a);return 18===e.getToken()?Ve(e,t,r,n,a,i):i}function Re(e,t,r,n,o,a,i){const s=e.getToken();if(!(4194304&~s)){2&e.assignable&&e.report(26),(!o&&1077936155===s&&"ArrayExpression"===i.type||"ObjectExpression"===i.type)&&_(e,i),j(e,32|t);const c=Ie(e,t,r,1,n,e.tokenStart);return e.assignable=2,e.finishNode(o?{type:"AssignmentPattern",left:i,right:c}:{type:"AssignmentExpression",left:i,operator:x[255&s],right:c},a)}return 8388608&~s||(i=Pe(e,t,r,n,a,4,s,i)),z(e,32|t,22)&&(i=Ue(e,t,r,i,a)),i}function Be(e,t,r,n,o,a,i){const s=e.getToken();j(e,32|t);const c=Ie(e,t,r,1,n,e.tokenStart);return i=e.finishNode(o?{type:"AssignmentPattern",left:i,right:c}:{type:"AssignmentExpression",left:i,operator:x[255&s],right:c},a),e.assignable=2,i}function Ue(e,t,r,n,o){const a=Ie(e,131072^(131072|t),r,1,0,e.tokenStart);X(e,32|t,21),e.assignable=1;const i=Ie(e,t,r,1,0,e.tokenStart);return e.assignable=2,e.finishNode({type:"ConditionalExpression",test:n,consequent:a,alternate:i},o)}function Pe(e,t,r,n,o,a,i,s){const c=8673330&-((131072&t)>0);let l,u;for(e.assignable=2;8388608&e.getToken()&&(l=e.getToken(),u=3840&l,(524288&l&&268435456&i||524288&i&&268435456&l)&&e.report(165),!(u+((8391735===l)<<8)-((c===l)<<12)<=a));)j(e,32|t),s=e.finishNode({type:524288&l||268435456&l?"LogicalExpression":"BinaryExpression",left:s,right:Pe(e,t,r,n,e.tokenStart,u,l,Ge(e,t,r,0,n,1)),operator:x[255&l]},o);return 1077936155===e.getToken()&&e.report(26),s}function Oe(e,t,r,n,o,a,i){const{tokenStart:s}=e;X(e,32|t,2162700);const c=[];if(1074790415!==e.getToken()){for(;134283267===e.getToken();){const{index:r,tokenStart:n,tokenIndex:o,tokenValue:a}=e,s=e.getToken(),l=rt(e,t);if(H(e,r,o,a)){if(t|=1,128&e.flags)throw new T(n,e.currentLocation,66);if(64&e.flags)throw new T(n,e.currentLocation,9);if(4096&e.flags)throw new T(n,e.currentLocation,15);i?.reportScopeError()}c.push(Te(e,t,l,s,n))}1&t&&(a&&(537079808&~a||e.report(119),36864&~a||e.report(40)),512&e.flags&&e.report(119),256&e.flags&&e.report(118))}for(e.flags=4928^(4928|e.flags),e.destructible=256^(256|e.destructible);1074790415!==e.getToken();)c.push(ge(e,t,r,n,4,{}));return X(e,24&o?32|t:t,1074790415),e.flags&=-4289,1077936155===e.getToken()&&e.report(26),e.finishNode({type:"BlockStatement",body:c},s)}function Ge(e,t,r,n,o,a){const i=e.tokenStart;return je(e,t,r,Me(e,t,r,2,0,n,o,a,i),o,0,i)}function je(e,t,r,n,o,a,i){if(33619968&~e.getToken()||1&e.flags){if(!(67108864&~e.getToken())){switch(t=131072^(131072|t),e.getToken()){case 67108877:{j(e,8^(262152|t)),16&t&&130===e.getToken()&&"super"===e.tokenValue&&e.report(173),e.assignable=1;const o=Fe(e,64|t,r);n=e.finishNode({type:"MemberExpression",object:n,computed:!1,property:o,optional:!1},i);break}case 69271571:{let a=!1;2048&~e.flags||(a=!0,e.flags=2048^(2048|e.flags)),j(e,32|t);const{tokenStart:s}=e,c=De(e,t,r,o,1,s);X(e,t,20),e.assignable=1,n=e.finishNode({type:"MemberExpression",object:n,computed:!0,property:c,optional:!1},i),a&&(e.flags|=2048);break}case 67174411:{if(!(1024&~e.flags))return e.flags=1024^(1024|e.flags),n;let a=!1;2048&~e.flags||(a=!0,e.flags=2048^(2048|e.flags));const s=et(e,t,r,o);e.assignable=2,n=e.finishNode({type:"CallExpression",callee:n,arguments:s,optional:!1},i),a&&(e.flags|=2048);break}case 67108990:j(e,8^(262152|t)),e.flags|=2048,e.assignable=2,n=function(e,t,r,n,o){let a,i=!1;69271571!==e.getToken()&&67174411!==e.getToken()||2048&~e.flags||(i=!0,e.flags=2048^(2048|e.flags));if(69271571===e.getToken()){j(e,32|t);const{tokenStart:i}=e,s=De(e,t,r,0,1,i);X(e,t,20),e.assignable=2,a=e.finishNode({type:"MemberExpression",object:n,computed:!0,optional:!0,property:s},o)}else if(67174411===e.getToken()){const i=et(e,t,r,0);e.assignable=2,a=e.finishNode({type:"CallExpression",callee:n,arguments:i,optional:!0},o)}else{const i=Fe(e,t,r);e.assignable=2,a=e.finishNode({type:"MemberExpression",object:n,computed:!1,optional:!0,property:i},o)}i&&(e.flags|=2048);return a}(e,t,r,n,i);break;default:2048&~e.flags||e.report(166),e.assignable=2,n=e.finishNode({type:"TaggedTemplateExpression",tag:n,quasi:67174408===e.getToken()?Ze(e,64|t,r):We(e,t)},i)}n=je(e,t,r,n,0,1,i)}}else n=function(e,t,r,n){2&e.assignable&&e.report(55);const o=e.getToken();return j(e,t),e.assignable=2,e.finishNode({type:"UpdateExpression",argument:r,operator:x[255&o],prefix:!1},n)}(e,t,n,i);return 0!==a||2048&~e.flags||(e.flags=2048^(2048|e.flags),n=e.finishNode({type:"ChainExpression",expression:n},i)),n}function Fe(e,t,r){return 143360&e.getToken()||-2147483528===e.getToken()||-2147483527===e.getToken()||130===e.getToken()||e.report(160),130===e.getToken()?vt(e,t,r,0):tt(e,t)}function Me(e,t,r,n,o,a,i,s,c){if(!(143360&~e.getToken())){switch(e.getToken()){case 209006:return function(e,t,r,n,o,a){o&&(e.destructible|=128),524288&t&&e.report(177);const i=pt(e,t,r);if("ArrowFunctionExpression"===i.type||!(65536&e.getToken())){if(2048&t)throw new T(a,{index:e.startIndex,line:e.startLine,column:e.startColumn},176);if(2&t)throw new T(a,{index:e.startIndex,line:e.startLine,column:e.startColumn},110);if(8192&t&&2048&t)throw new T(a,{index:e.startIndex,line:e.startLine,column:e.startColumn},110);return i}if(8192&t)throw new T(a,{index:e.startIndex,line:e.startLine,column:e.startColumn},31);if(2048&t||2&t&&8&t){if(n)throw new T(a,{index:e.startIndex,line:e.startLine,column:e.startColumn},0);const o=Ge(e,t,r,0,0,1);return 8391735===e.getToken()&&e.report(33),e.assignable=2,e.finishNode({type:"AwaitExpression",argument:o},a)}if(2&t)throw new T(a,{index:e.startIndex,line:e.startLine,column:e.startColumn},98);return i}(e,t,r,o,i,c);case 241771:return function(e,t,r,n,o,a){if(n&&(e.destructible|=256),1024&t){j(e,32|t),8192&t&&e.report(32),o||e.report(26),22===e.getToken()&&e.report(124);let n=null,i=!1;return 1&e.flags?8391476===e.getToken()&&e.report(30,x[255&e.getToken()]):(i=z(e,32|t,8391476),(77824&e.getToken()||i)&&(n=Ie(e,t,r,1,0,e.tokenStart))),e.assignable=2,e.finishNode({type:"YieldExpression",argument:n,delegate:i},a)}return 1&t&&e.report(97,"yield"),pt(e,t,r)}(e,t,r,i,a,c);case 209005:return function(e,t,r,n,o,a,i,s){const c=e.getToken(),l=tt(e,t),{flags:u}=e;if(!(1&u)){if(86104===e.getToken())return ot(e,t,r,1,n,s);if(Q(t,e.getToken()))return o||e.report(0),36864&~e.getToken()||(e.flags|=256),mt(e,t,r,a,s)}return i||67174411!==e.getToken()?10===e.getToken()?(ee(e,t,c),i&&e.report(51),36864&~c||(e.flags|=256),dt(e,t,r,e.tokenValue,l,i,a,0,s)):(e.assignable=1,l):bt(e,t,r,l,a,1,0,u,s)}(e,t,r,i,s,a,o,c)}const{tokenValue:l}=e,u=e.getToken(),p=tt(e,64|t);return 10===e.getToken()?(s||e.report(0),ee(e,t,u),36864&~u||(e.flags|=256),dt(e,t,r,l,p,o,a,0,c)):(!(16&t)||32768&t||8192&t||"arguments"!==e.tokenValue||e.report(130),73==(255&u)&&(1&t&&e.report(113),24&n&&e.report(100)),e.assignable=1&t&&!(537079808&~u)?2:1,p)}if(!(134217728&~e.getToken()))return rt(e,t);switch(e.getToken()){case 33619993:case 33619994:return function(e,t,r,n,o,a){n&&e.report(56),o||e.report(0);const i=e.getToken();j(e,32|t);const s=Ge(e,t,r,0,0,1);return 2&e.assignable&&e.report(55),e.assignable=2,e.finishNode({type:"UpdateExpression",argument:s,operator:x[255&i],prefix:!0},a)}(e,t,r,o,s,c);case 16863276:case 16842798:case 16842799:case 25233968:case 25233969:case 16863275:case 16863277:return function(e,t,r,n,o){n||e.report(0);const{tokenStart:a}=e,i=e.getToken();j(e,32|t);const s=Ge(e,t,r,0,o,1);var c;return 8391735===e.getToken()&&e.report(33),1&t&&16863276===i&&("Identifier"===s.type?e.report(121):(c=s).property&&"PrivateIdentifier"===c.property.type&&e.report(127)),e.assignable=2,e.finishNode({type:"UnaryExpression",operator:x[255&i],argument:s,prefix:!0},a)}(e,t,r,s,i);case 86104:return ot(e,t,r,0,i,c);case 2162700:return function(e,t,r,n,o){const a=lt(e,t,void 0,r,n,o,0,2,0);64&e.destructible&&e.report(63);8&e.destructible&&e.report(62);return a}(e,t,r,a?0:1,i);case 69271571:return function(e,t,r,n,o){const a=at(e,t,void 0,r,n,o,0,2,0);64&e.destructible&&e.report(63);8&e.destructible&&e.report(62);return a}(e,t,r,a?0:1,i);case 67174411:return function(e,t,r,n,o,a,i){e.flags=128^(128|e.flags);const s=e.tokenStart;j(e,262176|t);const c=e.createScopeIfLexical()?.createChildScope(512);if(t=131072^(131072|t),z(e,t,16))return gt(e,t,c,r,[],n,0,i);let l,u=0;e.destructible&=-385;let p=[],d=0,g=0,f=0;const k=e.tokenStart;e.assignable=1;for(;16!==e.getToken();){const{tokenStart:n}=e,i=e.getToken();if(143360&i)c?.addBlockName(t,e.tokenValue,1,0),537079808&~i?36864&~i||(f=1):g=1,l=Me(e,t,r,o,0,1,1,1,n),16===e.getToken()||18===e.getToken()?2&e.assignable&&(u|=16,g=1):(1077936155===e.getToken()?g=1:u|=16,l=je(e,t,r,l,1,0,n),16!==e.getToken()&&18!==e.getToken()&&(l=Re(e,t,r,1,0,n,l)));else{if(2097152&~i){if(14===i){l=st(e,t,c,r,16,o,a,0,1,0),16&e.destructible&&e.report(74),g=1,!d||16!==e.getToken()&&18!==e.getToken()||p.push(l),u|=8;break}if(u|=16,l=Ie(e,t,r,1,1,n),!d||16!==e.getToken()&&18!==e.getToken()||p.push(l),18===e.getToken()&&(d||(d=1,p=[l])),d){for(;z(e,32|t,18);)p.push(Ie(e,t,r,1,1,e.tokenStart));e.assignable=2,l=e.finishNode({type:"SequenceExpression",expressions:p},k)}return X(e,t,16),e.destructible=u,e.options.preserveParens?e.finishNode({type:"ParenthesizedExpression",expression:l},s):l}l=2162700===i?lt(e,262144|t,c,r,0,1,0,o,a):at(e,262144|t,c,r,0,1,0,o,a),u|=e.destructible,g=1,e.assignable=2,16!==e.getToken()&&18!==e.getToken()&&(8&u&&e.report(122),l=je(e,t,r,l,0,0,n),u|=16,16!==e.getToken()&&18!==e.getToken()&&(l=Re(e,t,r,0,0,n,l)))}if(!d||16!==e.getToken()&&18!==e.getToken()||p.push(l),!z(e,32|t,18))break;if(d||(d=1,p=[l]),16===e.getToken()){u|=8;break}}d&&(e.assignable=2,l=e.finishNode({type:"SequenceExpression",expressions:p},k));X(e,t,16),16&u&&8&u&&e.report(151);if(u|=256&e.destructible?256:128&e.destructible?128:0,10===e.getToken())return 48&u&&e.report(49),2050&t&&128&u&&e.report(31),1025&t&&256&u&&e.report(32),g&&(e.flags|=128),f&&(e.flags|=256),gt(e,t,c,r,d?p:[l],n,0,i);64&u&&e.report(63);8&u&&e.report(144);return e.destructible=256^(256|e.destructible)|u,e.options.preserveParens?e.finishNode({type:"ParenthesizedExpression",expression:l},s):l}(e,64|t,r,a,1,0,c);case 86021:case 86022:case 86023:return function(e,t){const r=e.tokenStart,n=x[255&e.getToken()],o=86023===e.getToken()?null:"true"===n,a={type:"Literal",value:o};e.options.raw&&(a.raw=n);return j(e,t),e.assignable=2,e.finishNode(a,r)}(e,t);case 86111:return function(e,t){const{tokenStart:r}=e;return j(e,t),e.assignable=2,e.finishNode({type:"ThisExpression"},r)}(e,t);case 65540:return function(e,t){const{tokenRaw:r,tokenRegExp:n,tokenValue:o,tokenStart:a}=e;j(e,t),e.assignable=2;const i={type:"Literal",value:o,regex:n};e.options.raw&&(i.raw=r);return e.finishNode(i,a)}(e,t);case 132:case 86094:return function(e,t,r,n,o){let a=null,i=null;const s=yt(e,t,r);t=16384^(16385|t),j(e,t),4096&e.getToken()&&20565!==e.getToken()&&(W(e,t,e.getToken())&&e.report(118),537079808&~e.getToken()||e.report(119),a=tt(e,t));let c=t;z(e,32|t,20565)?(i=Ge(e,t,r,0,n,0),c|=512):c=512^(512|c);const l=wt(e,c,t,void 0,r,2,0,n);return e.assignable=2,e.finishNode({type:"ClassExpression",id:a,superClass:i,body:l,...e.options.next?{decorators:s}:null},o)}(e,t,r,i,c);case 86109:return function(e,t){const{tokenStart:r}=e;switch(j(e,t),e.getToken()){case 67108990:e.report(167);case 67174411:512&t||e.report(28),e.assignable=2;break;case 69271571:case 67108877:256&t||e.report(29),e.assignable=1;break;default:e.report(30,"super")}return e.finishNode({type:"Super"},r)}(e,t);case 67174409:return We(e,t);case 67174408:return Ze(e,t,r);case 86107:return function(e,t,r,n){const{tokenStart:o}=e,a=tt(e,32|t),{tokenStart:i}=e;if(z(e,t,67108877)){if(65536&t&&209029===e.getToken())return e.assignable=2,function(e,t,r,n){const o=tt(e,t);return e.finishNode({type:"MetaProperty",meta:r,property:o},n)}(e,t,a,o);e.report(94)}e.assignable=2,16842752&~e.getToken()||e.report(65,x[255&e.getToken()]);const s=Me(e,t,r,2,1,0,n,1,i);t=131072^(131072|t),67108990===e.getToken()&&e.report(168);const c=ht(e,t,r,s,n,i);return e.assignable=2,e.finishNode({type:"NewExpression",callee:c,arguments:67174411===e.getToken()?et(e,t,r,n):[]},o)}(e,t,r,i);case 134283388:return Ye(e,t);case 130:return vt(e,t,r,0);case 86106:return function(e,t,r,n,o,a){let i=tt(e,t);if(67108877===e.getToken())return He(e,t,i,a);n&&e.report(142);return i=Je(e,t,r,o,a),e.assignable=2,je(e,t,r,i,o,0,a)}(e,t,r,o,i,c);case 8456256:if(e.options.jsx)return Nt(e,t,r,0,e.tokenStart);default:if(Q(t,e.getToken()))return pt(e,t,r);e.report(30,x[255&e.getToken()])}}function He(e,t,r,n){2&t||e.report(169),j(e,t);const o=e.getToken();return 209030!==o&&"meta"!==e.tokenValue?e.report(174):-2147483648&o&&e.report(175),e.assignable=2,e.finishNode({type:"MetaProperty",meta:r,property:tt(e,t)},n)}function Je(e,t,r,n,o){X(e,32|t,67174411),14===e.getToken()&&e.report(143);const a=Ie(e,t,r,1,n,e.tokenStart);let i=null;if(18===e.getToken()){if(X(e,t,18),16!==e.getToken()){i=Ie(e,131072^(131072|t),r,1,n,e.tokenStart)}z(e,t,18)}const s={type:"ImportExpression",source:a,options:i};return X(e,t,16),e.finishNode(s,o)}function ze(e,t){if(!z(e,t,20579))return[];X(e,t,2162700);const r=[],n=new Set;for(;1074790415!==e.getToken();){const o=e.tokenStart,a=_e(e,t);X(e,t,21);const i=Xe(e,t),s="Literal"===a.type?a.value:a.name;n.has(s)&&e.report(145,`${s}`),n.add(s),r.push(e.finishNode({type:"ImportAttribute",key:a,value:i},o)),1074790415!==e.getToken()&&X(e,t,18)}return X(e,t,1074790415),r}function Xe(e,t){if(134283267===e.getToken())return rt(e,t);e.report(30,x[255&e.getToken()])}function _e(e,t){return 134283267===e.getToken()?rt(e,t):143360&e.getToken()?tt(e,t):void e.report(30,x[255&e.getToken()])}function $e(e,t){if(134283267===e.getToken()){return e.tokenValue.isWellFormed()||e.report(171),rt(e,t)}if(143360&e.getToken())return tt(e,t);e.report(30,x[255&e.getToken()])}function Ye(e,t){const{tokenRaw:r,tokenValue:n,tokenStart:o}=e;j(e,t),e.assignable=2;const a={type:"Literal",value:n,bigint:String(n)};return e.options.raw&&(a.raw=r),e.finishNode(a,o)}function We(e,t){e.assignable=2;const{tokenValue:r,tokenRaw:n,tokenStart:o}=e;X(e,t,67174409);const a=[Ke(e,r,n,o,!0)];return e.finishNode({type:"TemplateLiteral",expressions:[],quasis:a},o)}function Ze(e,t,r){t=131072^(131072|t);const{tokenValue:n,tokenRaw:o,tokenStart:a}=e;X(e,-65&t|32,67174408);const i=[Ke(e,n,o,a,!1)],s=[De(e,-65&t,r,0,1,e.tokenStart)];for(1074790415!==e.getToken()&&e.report(83);67174409!==e.setToken(O(e,t),!0);){const{tokenValue:n,tokenRaw:o,tokenStart:a}=e;X(e,-65&t|32,67174408),i.push(Ke(e,n,o,a,!1)),s.push(De(e,t,r,0,1,e.tokenStart)),1074790415!==e.getToken()&&e.report(83)}{const{tokenValue:r,tokenRaw:n,tokenStart:o}=e;X(e,t,67174409),i.push(Ke(e,r,n,o,!0))}return e.finishNode({type:"TemplateLiteral",expressions:s,quasis:i},a)}function Ke(e,t,r,n,o){const a=e.finishNode({type:"TemplateElement",value:{cooked:t,raw:r},tail:o},n),i=o?1:2;return e.options.ranges&&(a.start+=1,a.range[0]+=1,a.end-=i,a.range[1]-=i),e.options.loc&&(a.loc.start.column+=1,a.loc.end.column-=i),a}function Qe(e,t,r){const n=e.tokenStart;X(e,32|(t=131072^(131072|t)),14);const o=Ie(e,t,r,1,0,e.tokenStart);return e.assignable=1,e.finishNode({type:"SpreadElement",argument:o},n)}function et(e,t,r,n){j(e,32|t);const o=[];if(16===e.getToken())return j(e,64|t),o;for(;16!==e.getToken()&&(14===e.getToken()?o.push(Qe(e,t,r)):o.push(Ie(e,t,r,1,n,e.tokenStart)),18===e.getToken())&&(j(e,32|t),16!==e.getToken()););return X(e,64|t,16),o}function tt(e,t){const{tokenValue:r,tokenStart:n}=e,o="await"===r&&!(-2147483648&e.getToken());return j(e,t|(o?32:0)),e.finishNode({type:"Identifier",name:r},n)}function rt(e,t){const{tokenValue:r,tokenRaw:n,tokenStart:o}=e;if(134283388===e.getToken())return Ye(e,t);const a={type:"Literal",value:r};return e.options.raw&&(a.raw=n),j(e,t),e.assignable=2,e.finishNode(a,o)}function nt(e,t,r,n,o,a,i,s,c){j(e,32|t);const l=a?J(e,t,8391476):0;let u,p=null,d=r?e.createScope():void 0;if(67174411===e.getToken())1&i||e.report(39,"Function");else{const n=!(4&o)||8&t&&2&t?64|(s?1024:0)|(l?1024:0):4;Y(e,t,e.getToken()),r&&(4&n?r.addVarName(t,e.tokenValue,n):r.addBlockName(t,e.tokenValue,n,o),d=d?.createChildScope(128),i&&2&i&&e.declareUnboundVariable(e.tokenValue)),u=e.getToken(),143360&e.getToken()?p=tt(e,t):e.report(30,x[255&e.getToken()])}{const e=28416;t=(t|e)^e|65536|(s?2048:0)|(l?1024:0)|(l?0:262144)}d=d?.createChildScope(256);const g=kt(e,-524289&t|8192,d,n,0,1),f=524428,k=Oe(e,36864|(t|f)^f,d?.createChildScope(64),n,8,u,d);return e.finishNode({type:"FunctionDeclaration",id:p,params:g,body:k,async:1===s,generator:1===l},c)}function ot(e,t,r,n,o,a){j(e,32|t);const i=J(e,t,8391476),s=(n?2048:0)|(i?1024:0);let c,l=null,u=e.createScopeIfLexical();const p=552704;143360&e.getToken()&&(Y(e,(t|p)^p|s,e.getToken()),u=u?.createChildScope(128),c=e.getToken(),l=tt(e,t)),t=(t|p)^p|65536|s|(i?0:262144),u=u?.createChildScope(256);const d=kt(e,-524289&t|8192,u,r,o,1),g=Oe(e,36864|-131229&t,u?.createChildScope(64),r,0,c,u);return e.assignable=2,e.finishNode({type:"FunctionExpression",id:l,params:d,body:g,async:1===n,generator:1===i},a)}function at(e,t,r,n,o,a,i,s,c){const{tokenStart:l}=e;j(e,32|t);const u=[];let p=0;for(t=131072^(131072|t);20!==e.getToken();)if(z(e,32|t,18))u.push(null);else{let o;const{tokenStart:l,tokenValue:d}=e,g=e.getToken();if(143360&g)if(o=Me(e,t,n,s,0,1,a,1,l),1077936155===e.getToken()){2&e.assignable&&e.report(26),j(e,32|t),r?.addVarOrBlock(t,d,s,c);const u=Ie(e,t,n,1,a,e.tokenStart);o=e.finishNode(i?{type:"AssignmentPattern",left:o,right:u}:{type:"AssignmentExpression",operator:"=",left:o,right:u},l),p|=256&e.destructible?256:128&e.destructible?128:0}else 18===e.getToken()||20===e.getToken()?(2&e.assignable?p|=16:r?.addVarOrBlock(t,d,s,c),p|=256&e.destructible?256:128&e.destructible?128:0):(p|=1&s?32:2&s?0:16,o=je(e,t,n,o,a,0,l),18!==e.getToken()&&20!==e.getToken()?(1077936155!==e.getToken()&&(p|=16),o=Re(e,t,n,a,i,l,o)):1077936155!==e.getToken()&&(p|=2&e.assignable?16:32));else 2097152&g?(o=2162700===e.getToken()?lt(e,t,r,n,0,a,i,s,c):at(e,t,r,n,0,a,i,s,c),p|=e.destructible,e.assignable=16&e.destructible?2:1,18===e.getToken()||20===e.getToken()?2&e.assignable&&(p|=16):8&e.destructible?e.report(71):(o=je(e,t,n,o,a,0,l),p=2&e.assignable?16:0,18!==e.getToken()&&20!==e.getToken()?o=Re(e,t,n,a,i,l,o):1077936155!==e.getToken()&&(p|=2&e.assignable?16:32))):14===g?(o=st(e,t,r,n,20,s,c,0,a,i),p|=e.destructible,18!==e.getToken()&&20!==e.getToken()&&e.report(30,x[255&e.getToken()])):(o=Ge(e,t,n,1,0,1),18!==e.getToken()&&20!==e.getToken()?(o=Re(e,t,n,a,i,l,o),3&s||67174411!==g||(p|=16)):2&e.assignable?p|=16:67174411===g&&(p|=1&e.assignable&&3&s?32:16));if(u.push(o),!z(e,32|t,18))break;if(20===e.getToken())break}X(e,t,20);const d=e.finishNode({type:i?"ArrayPattern":"ArrayExpression",elements:u},l);return!o&&4194304&e.getToken()?it(e,t,n,p,a,i,l,d):(e.destructible=p,d)}function it(e,t,r,n,o,a,i,s){1077936155!==e.getToken()&&e.report(26),j(e,32|t),16&n&&e.report(26),a||_(e,s);const{tokenStart:c}=e,l=Ie(e,t,r,1,o,c);return e.destructible=72^(72|n)|(128&e.destructible?128:0)|(256&e.destructible?256:0),e.finishNode(a?{type:"AssignmentPattern",left:s,right:l}:{type:"AssignmentExpression",left:s,operator:"=",right:l},i)}function st(e,t,r,n,o,a,i,s,c,l){const{tokenStart:u}=e;j(e,32|t);let p=null,d=0;const{tokenValue:g,tokenStart:f}=e;let k=e.getToken();if(143360&k)e.assignable=1,p=Me(e,t,n,a,0,1,c,1,f),k=e.getToken(),p=je(e,t,n,p,c,0,f),18!==e.getToken()&&e.getToken()!==o&&(2&e.assignable&&1077936155===e.getToken()&&e.report(71),d|=16,p=Re(e,t,n,c,l,f,p)),2&e.assignable?d|=16:k===o||18===k?r?.addVarOrBlock(t,g,a,i):d|=32,d|=128&e.destructible?128:0;else if(k===o)e.report(41);else{if(!(2097152&k)){d|=32,p=Ge(e,t,n,1,c,1);const{tokenStart:r}=e,a=e.getToken();return 1077936155===a?(2&e.assignable&&e.report(26),p=Re(e,t,n,c,l,r,p),d|=16):(18===a?d|=16:a!==o&&(p=Re(e,t,n,c,l,r,p)),d|=1&e.assignable?32:16),e.destructible=d,e.getToken()!==o&&18!==e.getToken()&&e.report(161),e.finishNode({type:l?"RestElement":"SpreadElement",argument:p},u)}p=2162700===e.getToken()?lt(e,t,r,n,1,c,l,a,i):at(e,t,r,n,1,c,l,a,i),k=e.getToken(),1077936155!==k&&k!==o&&18!==k?(8&e.destructible&&e.report(71),p=je(e,t,n,p,c,0,f),d|=2&e.assignable?16:0,4194304&~e.getToken()?(8388608&~e.getToken()||(p=Pe(e,t,n,1,f,4,k,p)),z(e,32|t,22)&&(p=Ue(e,t,n,p,f)),d|=2&e.assignable?16:32):(1077936155!==e.getToken()&&(d|=16),p=Re(e,t,n,c,l,f,p))):d|=1074790415===o&&1077936155!==k?16:e.destructible}if(e.getToken()!==o)if(1&a&&(d|=s?16:32),z(e,32|t,1077936155)){16&d&&e.report(26),_(e,p);const r=Ie(e,t,n,1,c,e.tokenStart);p=e.finishNode(l?{type:"AssignmentPattern",left:p,right:r}:{type:"AssignmentExpression",left:p,operator:"=",right:r},f),d=16}else d|=16;return e.destructible=d,e.finishNode({type:l?"RestElement":"SpreadElement",argument:p},u)}function ct(e,t,r,n,o,a){const i=11264|(64&n?0:16896);t=98560|((t|i)^i|(8&n?1024:0)|(16&n?2048:0)|(64&n?16384:0));let s=e.createScopeIfLexical(256);const c=function(e,t,r,n,o,a,i){X(e,t,67174411);const s=[];if(e.flags=128^(128|e.flags),16===e.getToken())return 512&o&&e.report(37,"Setter","one",""),j(e,t),s;256&o&&e.report(37,"Getter","no","s");512&o&&14===e.getToken()&&e.report(38);t=131072^(131072|t);let c=0,l=0;for(;18!==e.getToken();){let u=null;const{tokenStart:p}=e;if(143360&e.getToken()?(1&t||(36864&~e.getToken()||(e.flags|=256),537079808&~e.getToken()||(e.flags|=512)),u=Et(e,t,r,1|o,0)):(2162700===e.getToken()?u=lt(e,t,r,n,1,i,1,a,0):69271571===e.getToken()?u=at(e,t,r,n,1,i,1,a,0):14===e.getToken()&&(u=st(e,t,r,n,16,a,0,0,i,1)),l=1,48&e.destructible&&e.report(50)),1077936155===e.getToken()){j(e,32|t),l=1;const r=Ie(e,t,n,1,0,e.tokenStart);u=e.finishNode({type:"AssignmentPattern",left:u,right:r},p)}if(c++,s.push(u),!z(e,t,18))break;if(16===e.getToken())break}512&o&&1!==c&&e.report(37,"Setter","one","");r?.reportScopeError(),l&&(e.flags|=128);return X(e,t,16),s}(e,-524289&t|8192,s,r,n,1,o);s=s?.createChildScope(64);const l=Oe(e,36864|-655373&t,s,r,0,void 0,s?.parent);return e.finishNode({type:"FunctionExpression",params:c,body:l,async:(16&n)>0,generator:(8&n)>0,id:null},a)}function lt(e,t,r,n,o,a,i,s,c){const{tokenStart:l}=e;j(e,t);const u=[];let p=0,d=0;for(t=131072^(131072|t);1074790415!==e.getToken();){const{tokenValue:o,tokenStart:l}=e,g=e.getToken();if(14===g)u.push(st(e,t,r,n,1074790415,s,c,0,a,i));else{let f,k=0,h=null;if(143360&e.getToken()||-2147483528===e.getToken()||-2147483527===e.getToken())if(-2147483527===e.getToken()&&(p|=16),h=tt(e,t),18===e.getToken()||1074790415===e.getToken()||1077936155===e.getToken())if(k|=4,1&t&&!(537079808&~g)?p|=16:$(e,t,s,g,0),r?.addVarOrBlock(t,o,s,c),z(e,32|t,1077936155)){p|=8;const r=Ie(e,t,n,1,a,e.tokenStart);p|=256&e.destructible?256:128&e.destructible?128:0,f=e.finishNode({type:"AssignmentPattern",left:e.cloneIdentifier(h),right:r},l)}else p|=(209006===g?128:0)|(-2147483528===g?16:0),f=e.cloneIdentifier(h);else if(z(e,32|t,21)){const{tokenStart:l}=e;if("__proto__"===o&&d++,143360&e.getToken()){const o=e.getToken(),u=e.tokenValue;f=Me(e,t,n,s,0,1,a,1,l);const d=e.getToken();f=je(e,t,n,f,a,0,l),18===e.getToken()||1074790415===e.getToken()?1077936155===d||1074790415===d||18===d?(p|=128&e.destructible?128:0,2&e.assignable?p|=16:143360&~o||r?.addVarOrBlock(t,u,s,c)):p|=1&e.assignable?32:16:4194304&~e.getToken()?(p|=16,8388608&~e.getToken()||(f=Pe(e,t,n,1,l,4,d,f)),z(e,32|t,22)&&(f=Ue(e,t,n,f,l))):(2&e.assignable?p|=16:1077936155!==d?p|=32:r?.addVarOrBlock(t,u,s,c),f=Re(e,t,n,a,i,l,f))}else 2097152&~e.getToken()?(f=Ge(e,t,n,1,a,1),p|=1&e.assignable?32:16,18===e.getToken()||1074790415===e.getToken()?2&e.assignable&&(p|=16):(f=je(e,t,n,f,a,0,l),p=2&e.assignable?16:0,18!==e.getToken()&&1074790415!==g&&(1077936155!==e.getToken()&&(p|=16),f=Re(e,t,n,a,i,l,f)))):(f=69271571===e.getToken()?at(e,t,r,n,0,a,i,s,c):lt(e,t,r,n,0,a,i,s,c),p=e.destructible,e.assignable=16&p?2:1,18===e.getToken()||1074790415===e.getToken()?2&e.assignable&&(p|=16):8&e.destructible?e.report(71):(f=je(e,t,n,f,a,0,l),p=2&e.assignable?16:0,4194304&~e.getToken()?(8388608&~e.getToken()||(f=Pe(e,t,n,1,l,4,g,f)),z(e,32|t,22)&&(f=Ue(e,t,n,f,l)),p|=2&e.assignable?16:32):f=Be(e,t,n,a,i,l,f)))}else 69271571===e.getToken()?(p|=16,209005===g&&(k|=16),k|=2|(209008===g?256:209009===g?512:1),h=ut(e,t,n,a),p|=e.assignable,f=ct(e,t,n,k,a,e.tokenStart)):143360&e.getToken()?(p|=16,-2147483528===g&&e.report(95),209005===g?(1&e.flags&&e.report(132),k|=17):209008===g?k|=256:209009===g?k|=512:e.report(0),h=tt(e,t),f=ct(e,t,n,k,a,e.tokenStart)):67174411===e.getToken()?(p|=16,k|=1,f=ct(e,t,n,k,a,e.tokenStart)):8391476===e.getToken()?(p|=16,209008===g?e.report(42):209009===g?e.report(43):209005!==g&&e.report(30,x[52]),j(e,t),k|=9|(209005===g?16:0),143360&e.getToken()?h=tt(e,t):134217728&~e.getToken()?69271571===e.getToken()?(k|=2,h=ut(e,t,n,a),p|=e.assignable):e.report(30,x[255&e.getToken()]):h=rt(e,t),f=ct(e,t,n,k,a,e.tokenStart)):134217728&~e.getToken()?e.report(133):(209005===g&&(k|=16),k|=209008===g?256:209009===g?512:1,p|=16,h=rt(e,t),f=ct(e,t,n,k,a,e.tokenStart));else if(134217728&~e.getToken())if(69271571===e.getToken())if(h=ut(e,t,n,a),p|=256&e.destructible?256:0,k|=2,21===e.getToken()){j(e,32|t);const{tokenStart:o,tokenValue:l}=e,u=e.getToken();if(143360&e.getToken()){f=Me(e,t,n,s,0,1,a,1,o);const d=e.getToken();f=je(e,t,n,f,a,0,o),4194304&~e.getToken()?18===e.getToken()||1074790415===e.getToken()?1077936155===d||1074790415===d||18===d?2&e.assignable?p|=16:143360&~u||r?.addVarOrBlock(t,l,s,c):p|=1&e.assignable?32:16:(p|=16,f=Re(e,t,n,a,i,o,f)):(p|=2&e.assignable?16:1077936155===d?0:32,f=Be(e,t,n,a,i,o,f))}else 2097152&~e.getToken()?(f=Ge(e,t,n,1,0,1),p|=1&e.assignable?32:16,18===e.getToken()||1074790415===e.getToken()?2&e.assignable&&(p|=16):(f=je(e,t,n,f,a,0,o),p=1&e.assignable?0:16,18!==e.getToken()&&1074790415!==e.getToken()&&(1077936155!==e.getToken()&&(p|=16),f=Re(e,t,n,a,i,o,f)))):(f=69271571===e.getToken()?at(e,t,r,n,0,a,i,s,c):lt(e,t,r,n,0,a,i,s,c),p=e.destructible,e.assignable=16&p?2:1,18===e.getToken()||1074790415===e.getToken()?2&e.assignable&&(p|=16):8&p?e.report(62):(f=je(e,t,n,f,a,0,o),p=2&e.assignable?16|p:0,4194304&~e.getToken()?(8388608&~e.getToken()||(f=Pe(e,t,n,1,o,4,g,f)),z(e,32|t,22)&&(f=Ue(e,t,n,f,o)),p|=2&e.assignable?16:32):(1077936155!==e.getToken()&&(p|=16),f=Be(e,t,n,a,i,o,f))))}else 67174411===e.getToken()?(k|=1,f=ct(e,t,n,k,a,e.tokenStart),p=16):e.report(44);else if(8391476===g)if(X(e,32|t,8391476),k|=8,143360&e.getToken()){const r=e.getToken();if(h=tt(e,t),k|=1,67174411!==e.getToken())throw new T(e.tokenStart,e.currentLocation,209005===r?46:209008===r||209009===e.getToken()?45:47,x[255&r]);p|=16,f=ct(e,t,n,k,a,e.tokenStart)}else 134217728&~e.getToken()?69271571===e.getToken()?(p|=16,k|=3,h=ut(e,t,n,a),f=ct(e,t,n,k,a,e.tokenStart)):e.report(126):(p|=16,h=rt(e,t),k|=1,f=ct(e,t,n,k,a,e.tokenStart));else e.report(30,x[255&g]);else if(h=rt(e,t),21===e.getToken()){X(e,32|t,21);const{tokenStart:l}=e;if("__proto__"===o&&d++,143360&e.getToken()){f=Me(e,t,n,s,0,1,a,1,l);const{tokenValue:o}=e,u=e.getToken();f=je(e,t,n,f,a,0,l),18===e.getToken()||1074790415===e.getToken()?1077936155===u||1074790415===u||18===u?2&e.assignable?p|=16:r?.addVarOrBlock(t,o,s,c):p|=1&e.assignable?32:16:1077936155===e.getToken()?(2&e.assignable&&(p|=16),f=Re(e,t,n,a,i,l,f)):(p|=16,f=Re(e,t,n,a,i,l,f))}else 2097152&~e.getToken()?(f=Ge(e,t,n,1,0,1),p|=1&e.assignable?32:16,18===e.getToken()||1074790415===e.getToken()?2&e.assignable&&(p|=16):(f=je(e,t,n,f,a,0,l),p=1&e.assignable?0:16,18!==e.getToken()&&1074790415!==e.getToken()&&(1077936155!==e.getToken()&&(p|=16),f=Re(e,t,n,a,i,l,f)))):(f=69271571===e.getToken()?at(e,t,r,n,0,a,i,s,c):lt(e,t,r,n,0,a,i,s,c),p=e.destructible,e.assignable=16&p?2:1,18===e.getToken()||1074790415===e.getToken()?2&e.assignable&&(p|=16):8&~e.destructible&&(f=je(e,t,n,f,a,0,l),p=2&e.assignable?16:0,4194304&~e.getToken()?(8388608&~e.getToken()||(f=Pe(e,t,n,1,l,4,g,f)),z(e,32|t,22)&&(f=Ue(e,t,n,f,l)),p|=2&e.assignable?16:32):f=Be(e,t,n,a,i,l,f)))}else 67174411===e.getToken()?(k|=1,f=ct(e,t,n,k,a,e.tokenStart),p=16|e.assignable):e.report(134);p|=128&e.destructible?128:0,e.destructible=p,u.push(e.finishNode({type:"Property",key:h,value:f,kind:768&k?512&k?"set":"get":"init",computed:(2&k)>0,method:(1&k)>0,shorthand:(4&k)>0},l))}if(p|=e.destructible,18!==e.getToken())break;j(e,t)}X(e,t,1074790415),d>1&&(p|=64);const g=e.finishNode({type:i?"ObjectPattern":"ObjectExpression",properties:u},l);return!o&&4194304&e.getToken()?it(e,t,n,p,a,i,l,g):(e.destructible=p,g)}function ut(e,t,r,n){j(e,32|t);const o=Ie(e,131072^(131072|t),r,1,n,e.tokenStart);return X(e,t,20),o}function pt(e,t,r){const{tokenStart:n}=e,{tokenValue:o}=e;let a=0,i=0;537079808&~e.getToken()?36864&~e.getToken()||(i=1):a=1;const s=tt(e,t);if(e.assignable=1,10===e.getToken()){const c=e.options.lexical?le(e,t,o):void 0;return a&&(e.flags|=128),i&&(e.flags|=256),ft(e,t,c,r,[s],0,n)}return s}function dt(e,t,r,n,o,a,i,s,c){i||e.report(57),a&&e.report(51),e.flags&=-129;return ft(e,t,e.options.lexical?le(e,t,n):void 0,r,[o],s,c)}function gt(e,t,r,n,o,a,i,s){a||e.report(57);for(let t=0;t0&&"constructor"===e.tokenValue&&e.report(109),1074790415===e.getToken()&&e.report(108),z(e,t,1074790417)?i.length>0&&e.report(120):d.push(St(e,t,n,l,r,a,i,0,s,i.length>0?o:e.tokenStart))}return X(e,8&i?32|t:t,1074790415),l?.validatePrivateIdentifierRefs(),e.flags=-33&e.flags|p,e.finishNode({type:"ClassBody",body:d},c)}function St(e,t,r,n,o,a,i,s,c,l){let u=s?32:0,p=null;const d=e.getToken();if(176128&d||-2147483528===d)switch(p=tt(e,t),d){case 36970:if(!s&&67174411!==e.getToken()&&1048576&~e.getToken()&&1077936155!==e.getToken())return St(e,t,r,n,o,a,i,1,c,l);break;case 209005:if(67174411!==e.getToken()&&!(1&e.flags)){if(!(1073741824&~e.getToken()))return Ct(e,t,n,p,u,i,l);u|=16|(J(e,t,8391476)?8:0)}break;case 209008:if(67174411!==e.getToken()){if(!(1073741824&~e.getToken()))return Ct(e,t,n,p,u,i,l);u|=256}break;case 209009:if(67174411!==e.getToken()){if(!(1073741824&~e.getToken()))return Ct(e,t,n,p,u,i,l);u|=512}break;case 12402:if(67174411!==e.getToken()&&!(1&e.flags)){if(!(1073741824&~e.getToken()))return Ct(e,t,n,p,u,i,l);e.options.next&&(u|=1024)}}else if(69271571===d)u|=2,p=ut(e,o,n,c);else if(134217728&~d)if(8391476===d)u|=8,j(e,t);else if(130===e.getToken())u|=8192,p=vt(e,16|t,n,768);else if(1073741824&~e.getToken()){if(s&&2162700===d)return function(e,t,r,n,o){return r=r?.createChildScope(),ke(e,t=592128|5764^(5764|t),r,n,{},o,"StaticBlock")}(e,16|t,r,n,l);-2147483527===d?(p=tt(e,t),67174411!==e.getToken()&&e.report(30,x[255&e.getToken()])):e.report(30,x[255&e.getToken()])}else u|=128;else p=rt(e,t);if(1816&u&&(143360&e.getToken()||-2147483528===e.getToken()||-2147483527===e.getToken()?p=tt(e,t):134217728&~e.getToken()?69271571===e.getToken()?(u|=2,p=ut(e,t,n,0)):130===e.getToken()?(u|=8192,p=vt(e,t,n,u)):e.report(135):p=rt(e,t)),2&u||("constructor"===e.tokenValue?(1073741824&~e.getToken()?32&u||67174411!==e.getToken()||(920&u?e.report(53,"accessor"):512&t||(32&e.flags?e.report(54):e.flags|=32)):e.report(129),u|=64):!(8192&u)&&32&u&&"prototype"===e.tokenValue&&e.report(52)),1024&u||67174411!==e.getToken()&&!(768&u))return Ct(e,t,n,p,u,i,l);const g=ct(e,16|t,n,u,c,e.tokenStart);return e.finishNode({type:"MethodDefinition",kind:!(32&u)&&64&u?"constructor":256&u?"get":512&u?"set":"method",static:(32&u)>0,computed:(2&u)>0,key:p,value:g,...e.options.next?{decorators:i}:null},l)}function vt(e,t,r,n){const{tokenStart:o}=e;j(e,t);const{tokenValue:a}=e;return"constructor"===a&&e.report(128),e.options.lexical&&(r||e.report(4,a),n?r.addPrivateIdentifier(a,n):r.addPrivateIdentifierRef(a)),j(e,t),e.finishNode({type:"PrivateIdentifier",name:a},o)}function Ct(e,t,r,n,o,a,i){let s=null;if(8&o&&e.report(0),1077936155===e.getToken()){j(e,32|t);const{tokenStart:n}=e;537079927===e.getToken()&&e.report(119);const a=11264|(64&o?0:16896);s=Me(e,16|(t=65792|((t|a)^a|(8&o?1024:0)|(16&o?2048:0)|(64&o?16384:0))),r,2,0,1,0,1,n),!(1073741824&~e.getToken())&&4194304&~e.getToken()||(s=je(e,16|t,r,s,0,0,n),s=Re(e,16|t,r,0,0,n,s))}return M(e,t),e.finishNode({type:1024&o?"AccessorProperty":"PropertyDefinition",key:n,value:s,static:(32&o)>0,computed:(2&o)>0,...e.options.next?{decorators:a}:null},i)}function qt(e,t,r,n,o,a){if(143360&e.getToken()||!(1&t)&&-2147483527===e.getToken())return Et(e,t,r,o,a);2097152&~e.getToken()&&e.report(30,x[255&e.getToken()]);const i=69271571===e.getToken()?at(e,t,r,n,1,0,1,o,a):lt(e,t,r,n,1,0,1,o,a);return 16&e.destructible&&e.report(50),32&e.destructible&&e.report(50),i}function Et(e,t,r,n,o){const a=e.getToken();1&t&&(537079808&~a?36864&~a&&-2147483527!==a||e.report(118):e.report(119)),20480&~a||e.report(102),241771===a&&(1024&t&&e.report(32),2&t&&e.report(111)),73==(255&a)&&24&n&&e.report(100),209006===a&&(2048&t&&e.report(176),2&t&&e.report(110));const{tokenValue:i,tokenStart:s}=e;return j(e,t),r?.addVarOrBlock(t,i,n,o),e.finishNode({type:"Identifier",name:i},s)}function Nt(e,t,r,n,o){if(n||X(e,t,8456256),8390721===e.getToken()){const a=function(e,t){return ae(e),e.finishNode({type:"JSXOpeningFragment"},t)}(e,o),[i,s]=function(e,t,r,n){const o=[];for(;;){const a=At(e,t,r,n);if("JSXClosingFragment"===a.type)return[o,a];o.push(a)}}(e,t,r,n);return e.finishNode({type:"JSXFragment",openingFragment:a,children:i,closingFragment:s},o)}8457014===e.getToken()&&e.report(30,x[255&e.getToken()]);let a=null,i=[];const s=function(e,t,r,n,o){143360&~e.getToken()&&4096&~e.getToken()&&e.report(0);const a=Vt(e,t),i=function(e,t,r){const n=[];for(;8457014!==e.getToken()&&8390721!==e.getToken()&&1048576!==e.getToken();)n.push(Rt(e,t,r));return n}(e,t,r),s=8457014===e.getToken();s&&X(e,t,8457014);8390721!==e.getToken()&&e.report(25,x[65]);n||!s?ae(e):j(e,t);return e.finishNode({type:"JSXOpeningElement",name:a,attributes:i,selfClosing:s},o)}(e,t,r,n,o);if(!s.selfClosing){[i,a]=function(e,t,r,n){const o=[];for(;;){const a=Lt(e,t,r,n);if("JSXClosingElement"===a.type)return[o,a];o.push(a)}}(e,t,r,n);const o=K(a.name);K(s.name)!==o&&e.report(155,o)}return e.finishNode({type:"JSXElement",children:i,openingElement:s,closingElement:a},o)}function Lt(e,t,r,n){if(137===e.getToken())return It(e,t);if(2162700===e.getToken())return Ut(e,t,r,1,0);if(8456256===e.getToken()){const{tokenStart:o}=e;return j(e,t),8457014===e.getToken()?function(e,t,r,n){X(e,t,8457014);const o=Vt(e,t);return 8390721!==e.getToken()&&e.report(25,x[65]),r?ae(e):j(e,t),e.finishNode({type:"JSXClosingElement",name:o},n)}(e,t,n,o):Nt(e,t,r,1,o)}e.report(0)}function At(e,t,r,n){if(137===e.getToken())return It(e,t);if(2162700===e.getToken())return Ut(e,t,r,1,0);if(8456256===e.getToken()){const{tokenStart:o}=e;return j(e,t),8457014===e.getToken()?function(e,t,r,n){return X(e,t,8457014),8390721!==e.getToken()&&e.report(25,x[65]),r?ae(e):j(e,t),e.finishNode({type:"JSXClosingFragment"},n)}(e,t,n,o):Nt(e,t,r,1,o)}e.report(0)}function It(e,t){const r=e.tokenStart;j(e,t);const n={type:"JSXText",value:e.tokenValue};return e.options.raw&&(n.raw=e.tokenRaw),e.finishNode(n,r)}function Vt(e,t){const{tokenStart:r}=e;ie(e);let n=Pt(e,t);if(21===e.getToken())return Bt(e,t,n,r);for(;z(e,t,67108877);)ie(e),n=Dt(e,t,n,r);return n}function Dt(e,t,r,n){const o=Pt(e,t);return e.finishNode({type:"JSXMemberExpression",object:r,property:o},n)}function Rt(e,t,r){const{tokenStart:n}=e;if(2162700===e.getToken())return function(e,t,r){const n=e.tokenStart;j(e,t),X(e,t,14);const o=Ie(e,t,r,1,0,e.tokenStart);return X(e,t,1074790415),e.finishNode({type:"JSXSpreadAttribute",argument:o},n)}(e,t,r);ie(e);let o=null,a=Pt(e,t);if(21===e.getToken()&&(a=Bt(e,t,a,n)),1077936155===e.getToken()){switch(oe(e,t)){case 134283267:o=rt(e,t);break;case 8456256:o=Nt(e,t,r,0,e.tokenStart);break;case 2162700:o=Ut(e,t,r,0,1);break;default:e.report(154)}}return e.finishNode({type:"JSXAttribute",value:o,name:a},n)}function Bt(e,t,r,n){X(e,t,21);const o=Pt(e,t);return e.finishNode({type:"JSXNamespacedName",namespace:r,name:o},n)}function Ut(e,t,r,n,o){const{tokenStart:a}=e;j(e,32|t);const{tokenStart:i}=e;if(14===e.getToken())return function(e,t,r,n){X(e,t,14);const o=Ie(e,t,r,1,0,e.tokenStart);return X(e,t,1074790415),e.finishNode({type:"JSXSpreadChild",expression:o},n)}(e,t,r,a);let s=null;return 1074790415===e.getToken()?(o&&e.report(157),s=function(e,t){return e.finishNode({type:"JSXEmptyExpression"},t,e.tokenStart)}(e,{index:e.startIndex,line:e.startLine,column:e.startColumn})):s=Ie(e,t,r,1,0,i),1074790415!==e.getToken()&&e.report(25,x[15]),n?ae(e):j(e,t),e.finishNode({type:"JSXExpressionContainer",expression:s},a)}function Pt(e,t){const r=e.tokenStart;143360&e.getToken()||e.report(30,x[255&e.getToken()]);const{tokenValue:n}=e;return j(e,t),e.finishNode({type:"JSXIdentifier",name:n},r)}e.parse=function(e,t){return pe(e,t)},e.parseModule=function(e,t){return pe(e,{...t,sourceType:"module"})},e.parseScript=function(e,t){return pe(e,{...t,sourceType:"script"})},e.version="7.0.0"})); diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs deleted file mode 100644 index 2f494adc35..0000000000 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ /dev/null @@ -1,2055 +0,0 @@ -use std::collections::HashMap; -use std::collections::VecDeque; -use std::fmt; -#[cfg(unix)] -use std::os::unix::process::ExitStatusExt; -use std::path::Path; -use std::path::PathBuf; -use std::sync::Arc; -use std::time::Duration; - -use codex_protocol::ThreadId; -use codex_protocol::models::ContentItem; -use codex_protocol::models::DEFAULT_IMAGE_DETAIL; -use codex_protocol::models::FunctionCallOutputContentItem; -use codex_protocol::models::FunctionCallOutputPayload; -use codex_protocol::models::ImageDetail; -use codex_protocol::models::ResponseInputItem; -use serde::Deserialize; -use serde::Serialize; -use serde_json::Value as JsonValue; -use tokio::io::AsyncBufReadExt; -use tokio::io::AsyncWriteExt; -use tokio::io::BufReader; -use tokio::process::Child; -use tokio::process::ChildStdin; -use tokio::sync::Mutex; -use tokio::sync::Notify; -use tokio::sync::OnceCell; -use tokio_util::sync::CancellationToken; -use tracing::info; -use tracing::trace; -use tracing::warn; -use uuid::Uuid; - -use crate::exec::ExecCapturePolicy; -use crate::exec::ExecExpiration; -use crate::exec_env::create_env; -use crate::function_tool::FunctionCallError; -use crate::original_image_detail::normalize_output_image_detail; -use crate::sandboxing::ExecOptions; -use crate::session::session::Session; -use crate::session::turn_context::TurnContext; -use crate::tools::ToolRouter; -use crate::tools::context::SharedTurnDiffTracker; -use codex_sandboxing::SandboxCommand; -use codex_sandboxing::SandboxManager; -use codex_sandboxing::SandboxTransformRequest; -use codex_sandboxing::SandboxablePreference; -use codex_tools::ResponsesApiNamespaceTool; -use codex_tools::ToolName; -use codex_tools::ToolSpec; -use codex_utils_output_truncation::TruncationPolicy; -use codex_utils_output_truncation::truncate_text; - -pub(crate) const JS_REPL_PRAGMA_PREFIX: &str = "// codex-js-repl:"; -const KERNEL_SOURCE: &str = include_str!("kernel.js"); -const MERIYAH_UMD: &str = include_str!("meriyah.umd.min.js"); -const JS_REPL_MIN_NODE_VERSION: &str = include_str!("../../../../node-version.txt"); -const JS_REPL_STDERR_TAIL_LINE_LIMIT: usize = 20; -const JS_REPL_STDERR_TAIL_LINE_MAX_BYTES: usize = 512; -const JS_REPL_STDERR_TAIL_MAX_BYTES: usize = 4_096; -const JS_REPL_STDERR_TAIL_SEPARATOR: &str = " | "; -const JS_REPL_EXEC_ID_LOG_LIMIT: usize = 8; -const JS_REPL_MODEL_DIAG_STDERR_MAX_BYTES: usize = 1_024; -const JS_REPL_MODEL_DIAG_ERROR_MAX_BYTES: usize = 256; -const JS_REPL_TOOL_RESPONSE_TEXT_PREVIEW_MAX_BYTES: usize = 512; - -/// Per-task js_repl handle stored on the turn context. -pub(crate) struct JsReplHandle { - node_path: Option, - node_module_dirs: Vec, - cell: OnceCell>, -} - -impl fmt::Debug for JsReplHandle { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("JsReplHandle").finish_non_exhaustive() - } -} - -impl JsReplHandle { - pub(crate) fn with_node_path( - node_path: Option, - node_module_dirs: Vec, - ) -> Self { - Self { - node_path, - node_module_dirs, - cell: OnceCell::new(), - } - } - - pub(crate) async fn manager(&self) -> Result, FunctionCallError> { - self.cell - .get_or_try_init(|| async { - JsReplManager::new(self.node_path.clone(), self.node_module_dirs.clone()).await - }) - .await - .cloned() - } - - pub(crate) fn manager_if_initialized(&self) -> Option> { - self.cell.get().cloned() - } -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct JsReplArgs { - pub code: String, - #[serde(default)] - pub timeout_ms: Option, -} - -#[derive(Clone, Debug)] -pub struct JsExecResult { - pub output: String, - pub content_items: Vec, -} - -struct KernelState { - child: Arc>, - recent_stderr: Arc>>, - stdin: Arc>, - pending_execs: Arc>>>, - exec_contexts: Arc>>, - top_level_exec_state: TopLevelExecState, - shutdown: CancellationToken, -} - -#[derive(Clone)] -struct ExecContext { - session: Arc, - turn: Arc, - cancellation_token: CancellationToken, - tracker: SharedTurnDiffTracker, -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -enum TopLevelExecState { - #[default] - Idle, - FreshKernel { - turn_id: String, - exec_id: Option, - }, - ReusedKernelPending { - turn_id: String, - exec_id: String, - }, - Submitted { - turn_id: String, - exec_id: String, - }, -} - -impl TopLevelExecState { - fn registered_exec_id(&self) -> Option<&str> { - match self { - Self::Idle => None, - Self::FreshKernel { - exec_id: Some(exec_id), - .. - } - | Self::ReusedKernelPending { exec_id, .. } - | Self::Submitted { exec_id, .. } => Some(exec_id.as_str()), - Self::FreshKernel { exec_id: None, .. } => None, - } - } - - fn should_reset_for_interrupt(&self, turn_id: &str) -> bool { - match self { - Self::Idle => false, - Self::FreshKernel { - turn_id: active_turn_id, - .. - } - | Self::Submitted { - turn_id: active_turn_id, - .. - } => active_turn_id == turn_id, - Self::ReusedKernelPending { .. } => false, - } - } -} - -#[derive(Default)] -struct ExecToolCalls { - in_flight: usize, - content_items: Vec, - notify: Arc, - cancel: CancellationToken, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[allow(clippy::enum_variant_names)] -enum JsReplToolCallPayloadKind { - MessageContent, - FunctionText, - FunctionContentItems, - CustomText, - CustomContentItems, - McpResult, - McpErrorResult, - Error, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -struct JsReplToolCallResponseSummary { - response_type: Option, - payload_kind: Option, - payload_text_preview: Option, - payload_text_length: Option, - payload_item_count: Option, - text_item_count: Option, - image_item_count: Option, - structured_content_present: Option, - result_is_error: Option, -} - -enum KernelStreamEnd { - Shutdown, - StdoutEof, - StdoutReadError(String), -} - -impl KernelStreamEnd { - fn reason(&self) -> &'static str { - match self { - Self::Shutdown => "shutdown", - Self::StdoutEof => "stdout_eof", - Self::StdoutReadError(_) => "stdout_read_error", - } - } - - fn error(&self) -> Option<&str> { - match self { - Self::StdoutReadError(err) => Some(err), - _ => None, - } - } -} - -struct KernelDebugSnapshot { - pid: Option, - status: String, - stderr_tail: String, -} - -fn format_exit_status(status: std::process::ExitStatus) -> String { - if let Some(code) = status.code() { - return format!("code={code}"); - } - #[cfg(unix)] - if let Some(signal) = status.signal() { - return format!("signal={signal}"); - } - "unknown".to_string() -} - -fn format_stderr_tail(lines: &VecDeque) -> String { - if lines.is_empty() { - return "".to_string(); - } - lines - .iter() - .cloned() - .collect::>() - .join(JS_REPL_STDERR_TAIL_SEPARATOR) -} - -fn truncate_utf8_prefix_by_bytes(input: &str, max_bytes: usize) -> String { - if input.len() <= max_bytes { - return input.to_string(); - } - if max_bytes == 0 { - return String::new(); - } - let mut end = max_bytes; - while end > 0 && !input.is_char_boundary(end) { - end -= 1; - } - input[..end].to_string() -} - -fn stderr_tail_formatted_bytes(lines: &VecDeque) -> usize { - if lines.is_empty() { - return 0; - } - let payload_bytes: usize = lines.iter().map(String::len).sum(); - let separator_bytes = JS_REPL_STDERR_TAIL_SEPARATOR.len() * (lines.len() - 1); - payload_bytes + separator_bytes -} - -fn stderr_tail_bytes_with_candidate(lines: &VecDeque, line: &str) -> usize { - if lines.is_empty() { - return line.len(); - } - stderr_tail_formatted_bytes(lines) + JS_REPL_STDERR_TAIL_SEPARATOR.len() + line.len() -} - -fn push_stderr_tail_line(lines: &mut VecDeque, line: &str) -> String { - let max_line_bytes = JS_REPL_STDERR_TAIL_LINE_MAX_BYTES.min(JS_REPL_STDERR_TAIL_MAX_BYTES); - let bounded_line = truncate_utf8_prefix_by_bytes(line, max_line_bytes); - if bounded_line.is_empty() { - return bounded_line; - } - - while !lines.is_empty() - && (lines.len() >= JS_REPL_STDERR_TAIL_LINE_LIMIT - || stderr_tail_bytes_with_candidate(lines, &bounded_line) - > JS_REPL_STDERR_TAIL_MAX_BYTES) - { - lines.pop_front(); - } - - lines.push_back(bounded_line.clone()); - bounded_line -} - -fn is_kernel_status_exited(status: &str) -> bool { - status.starts_with("exited(") -} - -fn should_include_model_diagnostics_for_write_error( - err_message: &str, - snapshot: &KernelDebugSnapshot, -) -> bool { - is_kernel_status_exited(&snapshot.status) - || err_message.to_ascii_lowercase().contains("broken pipe") -} - -fn format_model_kernel_failure_details( - reason: &str, - stream_error: Option<&str>, - snapshot: &KernelDebugSnapshot, -) -> String { - let payload = serde_json::json!({ - "reason": reason, - "stream_error": stream_error - .map(|err| truncate_utf8_prefix_by_bytes(err, JS_REPL_MODEL_DIAG_ERROR_MAX_BYTES)), - "kernel_pid": snapshot.pid, - "kernel_status": snapshot.status, - "kernel_stderr_tail": truncate_utf8_prefix_by_bytes( - &snapshot.stderr_tail, - JS_REPL_MODEL_DIAG_STDERR_MAX_BYTES, - ), - }); - let encoded = serde_json::to_string(&payload) - .unwrap_or_else(|err| format!(r#"{{"reason":"serialization_error","error":"{err}"}}"#)); - format!("js_repl diagnostics: {encoded}") -} - -fn with_model_kernel_failure_message( - base_message: &str, - reason: &str, - stream_error: Option<&str>, - snapshot: &KernelDebugSnapshot, -) -> String { - format!( - "{base_message}\n\n{}", - format_model_kernel_failure_details(reason, stream_error, snapshot) - ) -} - -pub struct JsReplManager { - node_path: Option, - node_module_dirs: Vec, - tmp_dir: tempfile::TempDir, - kernel: Arc>>, - exec_lock: Arc, - exec_tool_calls: Arc>>, -} - -impl JsReplManager { - async fn new( - node_path: Option, - node_module_dirs: Vec, - ) -> Result, FunctionCallError> { - let tmp_dir = tempfile::tempdir().map_err(|err| { - FunctionCallError::RespondToModel(format!("failed to create js_repl temp dir: {err}")) - })?; - - let manager = Arc::new(Self { - node_path, - node_module_dirs, - tmp_dir, - kernel: Arc::new(Mutex::new(None)), - exec_lock: Arc::new(tokio::sync::Semaphore::new(1)), - exec_tool_calls: Arc::new(Mutex::new(HashMap::new())), - }); - - Ok(manager) - } - - async fn register_exec_tool_calls(&self, exec_id: &str) { - self.exec_tool_calls - .lock() - .await - .insert(exec_id.to_string(), ExecToolCalls::default()); - } - - async fn clear_exec_tool_calls(&self, exec_id: &str) { - if let Some(state) = self.exec_tool_calls.lock().await.remove(exec_id) { - state.cancel.cancel(); - state.notify.notify_waiters(); - } - } - - async fn wait_for_exec_tool_calls(&self, exec_id: &str) { - loop { - let notified = { - let calls = self.exec_tool_calls.lock().await; - calls - .get(exec_id) - .filter(|state| state.in_flight > 0) - .map(|state| Arc::clone(&state.notify).notified_owned()) - }; - match notified { - Some(notified) => notified.await, - None => return, - } - } - } - - async fn begin_exec_tool_call( - exec_tool_calls: &Arc>>, - exec_id: &str, - ) -> Option { - let mut calls = exec_tool_calls.lock().await; - let state = calls.get_mut(exec_id)?; - state.in_flight += 1; - Some(state.cancel.clone()) - } - - async fn record_exec_content_item( - exec_tool_calls: &Arc>>, - exec_id: &str, - content_item: FunctionCallOutputContentItem, - ) { - let mut calls = exec_tool_calls.lock().await; - if let Some(state) = calls.get_mut(exec_id) { - state.content_items.push(content_item); - } - } - - async fn finish_exec_tool_call( - exec_tool_calls: &Arc>>, - exec_id: &str, - ) { - let notify = { - let mut calls = exec_tool_calls.lock().await; - let Some(state) = calls.get_mut(exec_id) else { - return; - }; - if state.in_flight == 0 { - return; - } - state.in_flight -= 1; - if state.in_flight == 0 { - Some(Arc::clone(&state.notify)) - } else { - None - } - }; - if let Some(notify) = notify { - notify.notify_waiters(); - } - } - - async fn wait_for_exec_tool_calls_map( - exec_tool_calls: &Arc>>, - exec_id: &str, - ) { - loop { - let notified = { - let calls = exec_tool_calls.lock().await; - calls - .get(exec_id) - .filter(|state| state.in_flight > 0) - .map(|state| Arc::clone(&state.notify).notified_owned()) - }; - match notified { - Some(notified) => notified.await, - None => return, - } - } - } - - async fn clear_exec_tool_calls_map( - exec_tool_calls: &Arc>>, - exec_id: &str, - ) { - if let Some(state) = exec_tool_calls.lock().await.remove(exec_id) { - state.cancel.cancel(); - state.notify.notify_waiters(); - } - } - - async fn clear_all_exec_tool_calls_map( - exec_tool_calls: &Arc>>, - ) { - let states = { - let mut calls = exec_tool_calls.lock().await; - calls.drain().map(|(_, state)| state).collect::>() - }; - for state in states { - state.cancel.cancel(); - state.notify.notify_waiters(); - } - } - - async fn register_top_level_exec(&self, exec_id: String, turn_id: String) { - let mut kernel = self.kernel.lock().await; - let Some(state) = kernel.as_mut() else { - return; - }; - state.top_level_exec_state = match &state.top_level_exec_state { - TopLevelExecState::FreshKernel { - turn_id: active_turn_id, - .. - } if active_turn_id == &turn_id => TopLevelExecState::FreshKernel { - turn_id, - exec_id: Some(exec_id), - }, - TopLevelExecState::Idle - | TopLevelExecState::ReusedKernelPending { .. } - | TopLevelExecState::Submitted { .. } - | TopLevelExecState::FreshKernel { .. } => { - TopLevelExecState::ReusedKernelPending { turn_id, exec_id } - } - }; - } - - async fn mark_top_level_exec_submitted(&self, exec_id: &str) { - let mut kernel = self.kernel.lock().await; - let Some(state) = kernel.as_mut() else { - return; - }; - let next_state = match &state.top_level_exec_state { - TopLevelExecState::FreshKernel { - turn_id, - exec_id: Some(active_exec_id), - } - | TopLevelExecState::ReusedKernelPending { - turn_id, - exec_id: active_exec_id, - } if active_exec_id == exec_id => Some(TopLevelExecState::Submitted { - turn_id: turn_id.clone(), - exec_id: active_exec_id.clone(), - }), - TopLevelExecState::Idle - | TopLevelExecState::FreshKernel { .. } - | TopLevelExecState::ReusedKernelPending { .. } - | TopLevelExecState::Submitted { .. } => None, - }; - if let Some(next_state) = next_state { - state.top_level_exec_state = next_state; - } - } - - async fn clear_top_level_exec_if_matches(&self, exec_id: &str) { - Self::clear_top_level_exec_if_matches_map(&self.kernel, exec_id).await; - } - - async fn clear_top_level_exec_if_matches_map( - kernel: &Arc>>, - exec_id: &str, - ) { - let mut kernel = kernel.lock().await; - if let Some(state) = kernel.as_mut() - && state.top_level_exec_state.registered_exec_id() == Some(exec_id) - { - state.top_level_exec_state = TopLevelExecState::Idle; - } - } - - async fn clear_top_level_exec_if_matches_any_map( - kernel: &Arc>>, - exec_ids: &[String], - ) { - let mut kernel = kernel.lock().await; - if let Some(state) = kernel.as_mut() - && state - .top_level_exec_state - .registered_exec_id() - .is_some_and(|exec_id| exec_ids.iter().any(|pending_id| pending_id == exec_id)) - { - state.top_level_exec_state = TopLevelExecState::Idle; - } - } - - async fn turn_interrupt_requires_reset(&self, turn_id: &str) -> bool { - self.kernel.lock().await.as_ref().is_some_and(|state| { - state - .top_level_exec_state - .should_reset_for_interrupt(turn_id) - }) - } - - fn log_tool_call_response( - req: &RunToolRequest, - ok: bool, - summary: &JsReplToolCallResponseSummary, - response: Option<&JsonValue>, - error: Option<&str>, - ) { - info!( - exec_id = %req.exec_id, - tool_call_id = %req.id, - tool_name = %req.tool_name, - ok, - summary = ?summary, - "js_repl nested tool call completed" - ); - if let Some(response) = response { - trace!( - exec_id = %req.exec_id, - tool_call_id = %req.id, - tool_name = %req.tool_name, - response_json = %response, - "js_repl nested tool call raw response" - ); - } - if let Some(error) = error { - trace!( - exec_id = %req.exec_id, - tool_call_id = %req.id, - tool_name = %req.tool_name, - error = %error, - "js_repl nested tool call raw error" - ); - } - } - - fn summarize_text_payload( - response_type: Option<&str>, - payload_kind: JsReplToolCallPayloadKind, - text: &str, - ) -> JsReplToolCallResponseSummary { - JsReplToolCallResponseSummary { - response_type: response_type.map(str::to_owned), - payload_kind: Some(payload_kind), - payload_text_preview: (!text.is_empty()).then(|| { - truncate_text( - text, - TruncationPolicy::Bytes(JS_REPL_TOOL_RESPONSE_TEXT_PREVIEW_MAX_BYTES), - ) - }), - payload_text_length: Some(text.len()), - ..Default::default() - } - } - - fn summarize_function_output_payload( - response_type: &str, - payload_kind: JsReplToolCallPayloadKind, - output: &FunctionCallOutputPayload, - ) -> JsReplToolCallResponseSummary { - let (payload_item_count, text_item_count, image_item_count) = - if let Some(items) = output.content_items() { - let text_item_count = items - .iter() - .filter(|item| matches!(item, FunctionCallOutputContentItem::InputText { .. })) - .count(); - let image_item_count = items.len().saturating_sub(text_item_count); - ( - Some(items.len()), - Some(text_item_count), - Some(image_item_count), - ) - } else { - (None, None, None) - }; - let payload_text = output.body.to_text(); - JsReplToolCallResponseSummary { - response_type: Some(response_type.to_string()), - payload_kind: Some(payload_kind), - payload_text_preview: payload_text.as_deref().and_then(|text| { - (!text.is_empty()).then(|| { - truncate_text( - text, - TruncationPolicy::Bytes(JS_REPL_TOOL_RESPONSE_TEXT_PREVIEW_MAX_BYTES), - ) - }) - }), - payload_text_length: payload_text.as_ref().map(String::len), - payload_item_count, - text_item_count, - image_item_count, - ..Default::default() - } - } - - fn summarize_message_payload(content: &[ContentItem]) -> JsReplToolCallResponseSummary { - let text_item_count = content - .iter() - .filter(|item| { - matches!( - item, - ContentItem::InputText { .. } | ContentItem::OutputText { .. } - ) - }) - .count(); - let image_item_count = content.len().saturating_sub(text_item_count); - let payload_text = content - .iter() - .filter_map(|item| match item { - ContentItem::InputText { text } | ContentItem::OutputText { text } - if !text.trim().is_empty() => - { - Some(text.as_str()) - } - ContentItem::InputText { .. } - | ContentItem::InputImage { .. } - | ContentItem::OutputText { .. } => None, - }) - .collect::>(); - let payload_text = if payload_text.is_empty() { - None - } else { - Some(payload_text.join("\n")) - }; - JsReplToolCallResponseSummary { - response_type: Some("message".to_string()), - payload_kind: Some(JsReplToolCallPayloadKind::MessageContent), - payload_text_preview: payload_text.as_deref().and_then(|text| { - (!text.is_empty()).then(|| { - truncate_text( - text, - TruncationPolicy::Bytes(JS_REPL_TOOL_RESPONSE_TEXT_PREVIEW_MAX_BYTES), - ) - }) - }), - payload_text_length: payload_text.as_ref().map(String::len), - payload_item_count: Some(content.len()), - text_item_count: Some(text_item_count), - image_item_count: Some(image_item_count), - ..Default::default() - } - } - - fn summarize_tool_call_response(response: &ResponseInputItem) -> JsReplToolCallResponseSummary { - match response { - ResponseInputItem::Message { content, .. } => Self::summarize_message_payload(content), - ResponseInputItem::FunctionCallOutput { output, .. } => { - let payload_kind = if output.content_items().is_some() { - JsReplToolCallPayloadKind::FunctionContentItems - } else { - JsReplToolCallPayloadKind::FunctionText - }; - Self::summarize_function_output_payload( - "function_call_output", - payload_kind, - output, - ) - } - ResponseInputItem::CustomToolCallOutput { output, .. } => { - let payload_kind = if output.content_items().is_some() { - JsReplToolCallPayloadKind::CustomContentItems - } else { - JsReplToolCallPayloadKind::CustomText - }; - Self::summarize_function_output_payload( - "custom_tool_call_output", - payload_kind, - output, - ) - } - ResponseInputItem::McpToolCallOutput { output, .. } => { - let function_output = output.as_function_call_output_payload(); - let payload_kind = if output.success() { - JsReplToolCallPayloadKind::McpResult - } else { - JsReplToolCallPayloadKind::McpErrorResult - }; - let mut summary = Self::summarize_function_output_payload( - "mcp_tool_call_output", - payload_kind, - &function_output, - ); - summary.payload_item_count = Some(output.content.len()); - summary.structured_content_present = Some(output.structured_content.is_some()); - summary.result_is_error = Some(!output.success()); - summary - } - ResponseInputItem::ToolSearchOutput { tools, .. } => JsReplToolCallResponseSummary { - response_type: Some("tool_search_output".to_string()), - payload_kind: Some(JsReplToolCallPayloadKind::FunctionText), - payload_text_preview: Some(serde_json::Value::Array(tools.clone()).to_string()), - payload_text_length: Some( - serde_json::Value::Array(tools.clone()).to_string().len(), - ), - payload_item_count: Some(tools.len()), - ..Default::default() - }, - } - } - - fn summarize_tool_call_error(error: &str) -> JsReplToolCallResponseSummary { - Self::summarize_text_payload( - /*response_type*/ None, - JsReplToolCallPayloadKind::Error, - error, - ) - } - - pub async fn reset(&self) -> Result<(), FunctionCallError> { - let _permit = self.exec_lock.clone().acquire_owned().await.map_err(|_| { - FunctionCallError::RespondToModel("js_repl execution unavailable".to_string()) - })?; - self.reset_kernel().await; - Self::clear_all_exec_tool_calls_map(&self.exec_tool_calls).await; - Ok(()) - } - - pub async fn interrupt_turn_exec(&self, turn_id: &str) -> Result { - let _permit = self.exec_lock.clone().acquire_owned().await.map_err(|_| { - FunctionCallError::RespondToModel("js_repl execution unavailable".to_string()) - })?; - if !self.turn_interrupt_requires_reset(turn_id).await { - return Ok(false); - } - self.reset_kernel().await; - Self::clear_all_exec_tool_calls_map(&self.exec_tool_calls).await; - Ok(true) - } - - async fn reset_kernel(&self) { - let state = { - let mut guard = self.kernel.lock().await; - guard.take() - }; - if let Some(state) = state { - state.shutdown.cancel(); - Self::kill_kernel_child(&state.child, "reset").await; - } - } - - #[cfg(test)] - pub async fn execute( - &self, - session: Arc, - turn: Arc, - tracker: SharedTurnDiffTracker, - args: JsReplArgs, - ) -> Result { - self.execute_with_cancellation(session, turn, CancellationToken::new(), tracker, args) - .await - } - - #[expect( - clippy::await_holding_invalid_type, - reason = "js_repl kernel initialization must be serialized with kernel state" - )] - pub async fn execute_with_cancellation( - &self, - session: Arc, - turn: Arc, - cancellation_token: CancellationToken, - tracker: SharedTurnDiffTracker, - args: JsReplArgs, - ) -> Result { - let _permit = self.exec_lock.clone().acquire_owned().await.map_err(|_| { - FunctionCallError::RespondToModel("js_repl execution unavailable".to_string()) - })?; - - let (stdin, pending_execs, exec_contexts, child, recent_stderr) = { - let mut kernel = self.kernel.lock().await; - if kernel.is_none() { - let dependency_env = session.dependency_env().await; - let mut state = self - .start_kernel( - Arc::clone(&turn), - &dependency_env, - Some(session.conversation_id), - ) - .await - .map_err(FunctionCallError::RespondToModel)?; - state.top_level_exec_state = TopLevelExecState::FreshKernel { - turn_id: turn.sub_id.clone(), - exec_id: None, - }; - *kernel = Some(state); - } - - let state = match kernel.as_ref() { - Some(state) => state, - None => { - return Err(FunctionCallError::RespondToModel( - "js_repl kernel unavailable".to_string(), - )); - } - }; - ( - Arc::clone(&state.stdin), - Arc::clone(&state.pending_execs), - Arc::clone(&state.exec_contexts), - Arc::clone(&state.child), - Arc::clone(&state.recent_stderr), - ) - }; - - let (req_id, rx) = { - let req_id = Uuid::new_v4().to_string(); - let (tx, rx) = tokio::sync::oneshot::channel(); - pending_execs.lock().await.insert(req_id.clone(), tx); - exec_contexts.lock().await.insert( - req_id.clone(), - ExecContext { - session: Arc::clone(&session), - turn: Arc::clone(&turn), - cancellation_token, - tracker, - }, - ); - (req_id, rx) - }; - self.register_top_level_exec(req_id.clone(), turn.sub_id.clone()) - .await; - self.register_exec_tool_calls(&req_id).await; - - let payload = HostToKernel::Exec { - id: req_id.clone(), - code: args.code, - timeout_ms: args.timeout_ms, - }; - - let write_result = { - // Treat the exec as submitted before the async pipe writes begin: once we start - // awaiting `write_all`, the kernel may already observe runnable JS even if the turn is - // aborted before control returns here. - self.mark_top_level_exec_submitted(&req_id).await; - let write_result = Self::write_message(&stdin, &payload).await; - match write_result { - Ok(()) => Ok(()), - Err(err) => { - self.clear_top_level_exec_if_matches(&req_id).await; - Err(err) - } - } - }; - - if let Err(err) = write_result { - if pending_execs.lock().await.remove(&req_id).is_some() { - self.clear_top_level_exec_if_matches(&req_id).await; - } - exec_contexts.lock().await.remove(&req_id); - self.clear_exec_tool_calls(&req_id).await; - let snapshot = Self::kernel_debug_snapshot(&child, &recent_stderr).await; - let err_message = err.to_string(); - warn!( - exec_id = %req_id, - error = %err_message, - kernel_pid = ?snapshot.pid, - kernel_status = %snapshot.status, - kernel_stderr_tail = %snapshot.stderr_tail, - "failed to submit js_repl exec request to kernel" - ); - let message = - if should_include_model_diagnostics_for_write_error(&err_message, &snapshot) { - with_model_kernel_failure_message( - &err_message, - "write_failed", - Some(&err_message), - &snapshot, - ) - } else { - err_message - }; - return Err(FunctionCallError::RespondToModel(message)); - } - - let timeout_ms = args.timeout_ms.unwrap_or(30_000); - let response = match tokio::time::timeout(Duration::from_millis(timeout_ms), rx).await { - Ok(Ok(msg)) => msg, - Ok(Err(_)) => { - let removed = pending_execs.lock().await.remove(&req_id).is_some(); - if removed { - self.clear_top_level_exec_if_matches(&req_id).await; - } - exec_contexts.lock().await.remove(&req_id); - self.wait_for_exec_tool_calls(&req_id).await; - self.clear_exec_tool_calls(&req_id).await; - let snapshot = Self::kernel_debug_snapshot(&child, &recent_stderr).await; - let message = if is_kernel_status_exited(&snapshot.status) { - with_model_kernel_failure_message( - "js_repl kernel closed unexpectedly", - "response_channel_closed", - /*stream_error*/ None, - &snapshot, - ) - } else { - "js_repl kernel closed unexpectedly".to_string() - }; - return Err(FunctionCallError::RespondToModel(message)); - } - Err(_) => { - self.reset_kernel().await; - self.wait_for_exec_tool_calls(&req_id).await; - self.exec_tool_calls.lock().await.clear(); - self.clear_top_level_exec_if_matches(&req_id).await; - return Err(FunctionCallError::RespondToModel( - "js_repl execution timed out; kernel reset, rerun your request".to_string(), - )); - } - }; - - match response { - ExecResultMessage::Ok { content_items } => { - let (output, content_items) = split_exec_result_content_items(content_items); - Ok(JsExecResult { - output, - content_items, - }) - } - ExecResultMessage::Err { message } => Err(FunctionCallError::RespondToModel(message)), - } - } - - async fn start_kernel( - &self, - turn: Arc, - dependency_env: &HashMap, - thread_id: Option, - ) -> Result { - let node_path = resolve_compatible_node(self.node_path.as_deref()).await?; - - let kernel_path = self - .write_kernel_script() - .await - .map_err(|err| err.to_string())?; - - let mut env = create_env(&turn.shell_environment_policy, thread_id); - if !dependency_env.is_empty() { - env.extend(dependency_env.clone()); - } - env.insert( - "CODEX_JS_TMP_DIR".to_string(), - self.tmp_dir.path().to_string_lossy().to_string(), - ); - let node_module_dirs_key = "CODEX_JS_REPL_NODE_MODULE_DIRS"; - if !self.node_module_dirs.is_empty() && !env.contains_key(node_module_dirs_key) { - let joined = std::env::join_paths(&self.node_module_dirs) - .map_err(|err| format!("failed to join js_repl_node_module_dirs: {err}"))?; - env.insert( - node_module_dirs_key.to_string(), - joined.to_string_lossy().to_string(), - ); - } - - let sandbox = SandboxManager::new(); - let managed_network_active = turn.network.is_some(); - let sandbox_type = sandbox.select_initial( - &turn.file_system_sandbox_policy, - turn.network_sandbox_policy, - SandboxablePreference::Auto, - turn.windows_sandbox_level, - managed_network_active, - ); - let command = SandboxCommand { - program: node_path.into_os_string(), - args: vec![ - "--experimental-vm-modules".to_string(), - kernel_path.to_string_lossy().to_string(), - ], - cwd: turn.cwd.clone(), - env, - additional_permissions: None, - }; - let options = ExecOptions { - expiration: ExecExpiration::DefaultTimeout, - capture_policy: ExecCapturePolicy::ShellTool, - }; - let exec_env = sandbox - .transform(SandboxTransformRequest { - command, - policy: &turn.sandbox_policy, - file_system_policy: &turn.file_system_sandbox_policy, - network_policy: turn.network_sandbox_policy, - sandbox: sandbox_type, - enforce_managed_network: managed_network_active, - network: None, - sandbox_policy_cwd: &turn.cwd, - codex_linux_sandbox_exe: turn.codex_linux_sandbox_exe.as_deref(), - use_legacy_landlock: turn.features.use_legacy_landlock(), - windows_sandbox_level: turn.windows_sandbox_level, - windows_sandbox_private_desktop: turn - .config - .permissions - .windows_sandbox_private_desktop, - }) - .map(|request| { - crate::sandboxing::ExecRequest::from_sandbox_exec_request( - request, - options, - turn.cwd.clone(), - ) - }) - .map_err(|err| format!("failed to configure sandbox for js_repl: {err}"))?; - - let mut cmd = - tokio::process::Command::new(exec_env.command.first().cloned().unwrap_or_default()); - if exec_env.command.len() > 1 { - cmd.args(&exec_env.command[1..]); - } - #[cfg(unix)] - cmd.arg0( - exec_env - .arg0 - .clone() - .unwrap_or_else(|| exec_env.command.first().cloned().unwrap_or_default()), - ); - cmd.current_dir(&exec_env.cwd); - cmd.env_clear(); - cmd.envs(exec_env.env); - cmd.stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true); - - let mut child = cmd - .spawn() - .map_err(|err| format!("failed to start Node runtime: {err}"))?; - let stdout = child - .stdout - .take() - .ok_or_else(|| "js_repl kernel missing stdout".to_string())?; - let stderr = child.stderr.take(); - let stdin = child - .stdin - .take() - .ok_or_else(|| "js_repl kernel missing stdin".to_string())?; - - let shutdown = CancellationToken::new(); - let pending_execs: Arc< - Mutex>>, - > = Arc::new(Mutex::new(HashMap::new())); - let exec_contexts: Arc>> = - Arc::new(Mutex::new(HashMap::new())); - let stdin_arc = Arc::new(Mutex::new(stdin)); - let child = Arc::new(Mutex::new(child)); - let recent_stderr = Arc::new(Mutex::new(VecDeque::with_capacity( - JS_REPL_STDERR_TAIL_LINE_LIMIT, - ))); - - tokio::spawn(Self::read_stdout( - stdout, - Arc::clone(&child), - Arc::clone(&self.kernel), - Arc::clone(&recent_stderr), - Arc::clone(&pending_execs), - Arc::clone(&exec_contexts), - Arc::clone(&self.exec_tool_calls), - Arc::clone(&stdin_arc), - shutdown.clone(), - )); - if let Some(stderr) = stderr { - tokio::spawn(Self::read_stderr( - stderr, - Arc::clone(&recent_stderr), - shutdown.clone(), - )); - } else { - warn!("js_repl kernel missing stderr"); - } - - Ok(KernelState { - child, - recent_stderr, - stdin: stdin_arc, - pending_execs, - exec_contexts, - top_level_exec_state: TopLevelExecState::Idle, - shutdown, - }) - } - - async fn write_kernel_script(&self) -> Result { - let dir = self.tmp_dir.path(); - let kernel_path = dir.join("js_repl_kernel.js"); - let meriyah_path = dir.join("meriyah.umd.min.js"); - tokio::fs::write(&kernel_path, KERNEL_SOURCE).await?; - tokio::fs::write(&meriyah_path, MERIYAH_UMD).await?; - Ok(kernel_path) - } - - #[expect( - clippy::await_holding_invalid_type, - reason = "js_repl stdin writes must be serialized per kernel" - )] - async fn write_message( - stdin: &Arc>, - msg: &HostToKernel, - ) -> Result<(), FunctionCallError> { - let encoded = serde_json::to_string(msg).map_err(|err| { - FunctionCallError::RespondToModel(format!("failed to serialize kernel message: {err}")) - })?; - let mut guard = stdin.lock().await; - guard.write_all(encoded.as_bytes()).await.map_err(|err| { - FunctionCallError::RespondToModel(format!("failed to write to kernel: {err}")) - })?; - guard.write_all(b"\n").await.map_err(|err| { - FunctionCallError::RespondToModel(format!("failed to flush kernel message: {err}")) - })?; - Ok(()) - } - - async fn kernel_stderr_tail_snapshot(recent_stderr: &Arc>>) -> String { - let tail = recent_stderr.lock().await; - format_stderr_tail(&tail) - } - - async fn kernel_debug_snapshot( - child: &Arc>, - recent_stderr: &Arc>>, - ) -> KernelDebugSnapshot { - let (pid, status) = { - let mut guard = child.lock().await; - let pid = guard.id(); - let status = match guard.try_wait() { - Ok(Some(status)) => format!("exited({})", format_exit_status(status)), - Ok(None) => "running".to_string(), - Err(err) => format!("unknown ({err})"), - }; - (pid, status) - }; - let stderr_tail = { - let tail = recent_stderr.lock().await; - format_stderr_tail(&tail) - }; - KernelDebugSnapshot { - pid, - status, - stderr_tail, - } - } - - #[expect( - clippy::await_holding_invalid_type, - reason = "js_repl child shutdown must serialize process inspection and termination" - )] - async fn kill_kernel_child(child: &Arc>, reason: &'static str) { - let mut guard = child.lock().await; - let pid = guard.id(); - match guard.try_wait() { - Ok(Some(_)) => return, - Ok(None) => {} - Err(err) => { - warn!( - kernel_pid = ?pid, - kill_reason = reason, - error = %err, - "failed to inspect js_repl kernel before kill" - ); - } - } - - if let Err(err) = guard.start_kill() { - warn!( - kernel_pid = ?pid, - kill_reason = reason, - error = %err, - "failed to send kill signal to js_repl kernel" - ); - return; - } - - match tokio::time::timeout(Duration::from_secs(2), guard.wait()).await { - Ok(Ok(_status)) => {} - Ok(Err(err)) => { - warn!( - kernel_pid = ?pid, - kill_reason = reason, - error = %err, - "failed while waiting for js_repl kernel exit" - ); - } - Err(_) => { - warn!( - kernel_pid = ?pid, - kill_reason = reason, - "timed out waiting for js_repl kernel to exit after kill" - ); - } - } - } - - fn truncate_id_list(ids: &[String]) -> Vec { - if ids.len() <= JS_REPL_EXEC_ID_LOG_LIMIT { - return ids.to_vec(); - } - let mut output = ids[..JS_REPL_EXEC_ID_LOG_LIMIT].to_vec(); - output.push(format!("...+{}", ids.len() - JS_REPL_EXEC_ID_LOG_LIMIT)); - output - } - - #[allow(clippy::too_many_arguments)] - async fn read_stdout( - stdout: tokio::process::ChildStdout, - child: Arc>, - manager_kernel: Arc>>, - recent_stderr: Arc>>, - pending_execs: Arc>>>, - exec_contexts: Arc>>, - exec_tool_calls: Arc>>, - stdin: Arc>, - shutdown: CancellationToken, - ) { - let mut reader = BufReader::new(stdout).lines(); - let end_reason = loop { - let line = tokio::select! { - _ = shutdown.cancelled() => break KernelStreamEnd::Shutdown, - res = reader.next_line() => match res { - Ok(Some(line)) => line, - Ok(None) => break KernelStreamEnd::StdoutEof, - Err(err) => break KernelStreamEnd::StdoutReadError(err.to_string()), - }, - }; - - let parsed: Result = serde_json::from_str(&line); - let msg = match parsed { - Ok(m) => m, - Err(err) => { - warn!("js_repl kernel sent invalid json: {err} (line: {line})"); - continue; - } - }; - - match msg { - KernelToHost::ExecResult { - id, - ok, - output, - error, - } => { - JsReplManager::wait_for_exec_tool_calls_map(&exec_tool_calls, &id).await; - let content_items = { - let calls = exec_tool_calls.lock().await; - calls - .get(&id) - .map(|state| state.content_items.clone()) - .unwrap_or_default() - }; - let tx = { - let mut pending = pending_execs.lock().await; - pending.remove(&id) - }; - if let Some(tx) = tx { - Self::clear_top_level_exec_if_matches_map(&manager_kernel, &id).await; - let payload = if ok { - ExecResultMessage::Ok { - content_items: build_exec_result_content_items( - output, - content_items, - ), - } - } else { - ExecResultMessage::Err { - message: error - .unwrap_or_else(|| "js_repl execution failed".to_string()), - } - }; - let _ = tx.send(payload); - } - exec_contexts.lock().await.remove(&id); - JsReplManager::clear_exec_tool_calls_map(&exec_tool_calls, &id).await; - } - KernelToHost::EmitImage(req) => { - let exec_id = req.exec_id.clone(); - let emit_id = req.id.clone(); - let context = exec_contexts.lock().await.get(&exec_id).cloned(); - let response = if let Some(ctx) = context { - match validate_emitted_image_url(&req.image_url) { - Ok(()) => { - let content_item = emitted_image_content_item( - ctx.turn.as_ref(), - req.image_url, - req.detail, - ); - JsReplManager::record_exec_content_item( - &exec_tool_calls, - &exec_id, - content_item, - ) - .await; - HostToKernel::EmitImageResult(EmitImageResult { - id: emit_id, - ok: true, - error: None, - }) - } - Err(error) => HostToKernel::EmitImageResult(EmitImageResult { - id: emit_id, - ok: false, - error: Some(error), - }), - } - } else { - HostToKernel::EmitImageResult(EmitImageResult { - id: emit_id, - ok: false, - error: Some("js_repl exec context not found".to_string()), - }) - }; - - if let Err(err) = JsReplManager::write_message(&stdin, &response).await { - let snapshot = - JsReplManager::kernel_debug_snapshot(&child, &recent_stderr).await; - warn!( - exec_id = %exec_id, - emit_id = %req.id, - error = %err, - kernel_pid = ?snapshot.pid, - kernel_status = %snapshot.status, - kernel_stderr_tail = %snapshot.stderr_tail, - "failed to reply to kernel emit_image request" - ); - } - } - KernelToHost::RunTool(req) => { - let Some(reset_cancel) = - JsReplManager::begin_exec_tool_call(&exec_tool_calls, &req.exec_id).await - else { - let exec_id = req.exec_id.clone(); - let tool_call_id = req.id.clone(); - let payload = HostToKernel::RunToolResult(RunToolResult { - id: req.id, - ok: false, - response: None, - error: Some("js_repl exec context not found".to_string()), - }); - if let Err(err) = JsReplManager::write_message(&stdin, &payload).await { - let snapshot = - JsReplManager::kernel_debug_snapshot(&child, &recent_stderr).await; - warn!( - exec_id = %exec_id, - tool_call_id = %tool_call_id, - error = %err, - kernel_pid = ?snapshot.pid, - kernel_status = %snapshot.status, - kernel_stderr_tail = %snapshot.stderr_tail, - "failed to reply to kernel run_tool request" - ); - } - continue; - }; - let stdin_clone = Arc::clone(&stdin); - let exec_contexts = Arc::clone(&exec_contexts); - let exec_tool_calls_for_task = Arc::clone(&exec_tool_calls); - let recent_stderr = Arc::clone(&recent_stderr); - tokio::spawn(async move { - let exec_id = req.exec_id.clone(); - let tool_call_id = req.id.clone(); - let tool_name = req.tool_name.clone(); - let context = exec_contexts.lock().await.get(&exec_id).cloned(); - let result = match context { - Some(ctx) => { - tokio::select! { - _ = reset_cancel.cancelled() => RunToolResult { - id: tool_call_id.clone(), - ok: false, - response: None, - error: Some("js_repl execution reset".to_string()), - }, - result = JsReplManager::run_tool_request(ctx, req) => result, - } - } - None => RunToolResult { - id: tool_call_id.clone(), - ok: false, - response: None, - error: Some("js_repl exec context not found".to_string()), - }, - }; - JsReplManager::finish_exec_tool_call(&exec_tool_calls_for_task, &exec_id) - .await; - let payload = HostToKernel::RunToolResult(result); - if let Err(err) = JsReplManager::write_message(&stdin_clone, &payload).await - { - let stderr_tail = - JsReplManager::kernel_stderr_tail_snapshot(&recent_stderr).await; - warn!( - exec_id = %exec_id, - tool_call_id = %tool_call_id, - tool_name = %tool_name, - error = %err, - kernel_stderr_tail = %stderr_tail, - "failed to reply to kernel run_tool request" - ); - } - }); - } - } - }; - - let exec_ids = { - let mut contexts = exec_contexts.lock().await; - let ids = contexts.keys().cloned().collect::>(); - contexts.clear(); - ids - }; - for exec_id in exec_ids { - JsReplManager::wait_for_exec_tool_calls_map(&exec_tool_calls, &exec_id).await; - JsReplManager::clear_exec_tool_calls_map(&exec_tool_calls, &exec_id).await; - } - let unexpected_snapshot = if matches!(end_reason, KernelStreamEnd::Shutdown) { - None - } else { - Some(Self::kernel_debug_snapshot(&child, &recent_stderr).await) - }; - let kernel_failure_message = unexpected_snapshot.as_ref().map(|snapshot| { - with_model_kernel_failure_message( - "js_repl kernel exited unexpectedly", - end_reason.reason(), - end_reason.error(), - snapshot, - ) - }); - let kernel_exit_message = kernel_failure_message - .clone() - .unwrap_or_else(|| "js_repl kernel exited unexpectedly".to_string()); - - { - let mut kernel = manager_kernel.lock().await; - let should_clear = kernel - .as_ref() - .is_some_and(|state| Arc::ptr_eq(&state.child, &child)); - if should_clear { - kernel.take(); - } - } - - let pending_execs_to_notify = { - let mut pending = pending_execs.lock().await; - pending.drain().collect::>() - }; - let mut pending_exec_ids = Vec::with_capacity(pending_execs_to_notify.len()); - for (id, tx) in pending_execs_to_notify { - pending_exec_ids.push(id); - let _ = tx.send(ExecResultMessage::Err { - message: kernel_exit_message.clone(), - }); - } - if !pending_exec_ids.is_empty() { - Self::clear_top_level_exec_if_matches_any_map(&manager_kernel, &pending_exec_ids).await; - } - - if !matches!(end_reason, KernelStreamEnd::Shutdown) { - let mut pending_exec_ids = pending_exec_ids; - pending_exec_ids.sort_unstable(); - let snapshot = Self::kernel_debug_snapshot(&child, &recent_stderr).await; - warn!( - reason = %end_reason.reason(), - stream_error = %end_reason.error().unwrap_or(""), - kernel_pid = ?snapshot.pid, - kernel_status = %snapshot.status, - pending_exec_count = pending_exec_ids.len(), - pending_exec_ids = ?Self::truncate_id_list(&pending_exec_ids), - kernel_stderr_tail = %snapshot.stderr_tail, - "js_repl kernel terminated unexpectedly" - ); - } - } - - #[expect( - clippy::await_holding_invalid_type, - reason = "nested js_repl tool routing reads through the session-owned manager guard" - )] - async fn run_tool_request(exec: ExecContext, req: RunToolRequest) -> RunToolResult { - if is_js_repl_internal_tool(&req.tool_name) { - let error = "js_repl cannot invoke itself".to_string(); - let summary = Self::summarize_tool_call_error(&error); - Self::log_tool_call_response( - &req, - /*ok*/ false, - &summary, - /*response*/ None, - Some(&error), - ); - return RunToolResult { - id: req.id, - ok: false, - response: None, - error: Some(error), - }; - } - - let mcp_tools = exec - .session - .services - .mcp_connection_manager - .read() - .await - .list_all_tools() - .await; - let router = ToolRouter::from_config( - &exec.turn.tools_config, - crate::tools::router::ToolRouterParams { - deferred_mcp_tools: None, - mcp_tools: Some(mcp_tools), - unavailable_called_tools: Vec::new(), - // JS REPL dispatches nested tool calls directly, not through - // `ToolCallRuntime`'s parallel scheduling lock. - parallel_mcp_server_names: std::collections::HashSet::new(), - discoverable_tools: None, - dynamic_tools: exec.turn.dynamic_tools.as_slice(), - }, - ); - - let specs = router.specs(); - let requested_tool_name = specs - .iter() - .find_map(|spec| match spec { - ToolSpec::Function(tool) if tool.name == req.tool_name => { - Some(ToolName::plain(req.tool_name.clone())) - } - ToolSpec::Freeform(tool) if tool.name == req.tool_name => { - Some(ToolName::plain(req.tool_name.clone())) - } - ToolSpec::Namespace(namespace) => { - namespace.tools.iter().find_map(|tool| match tool { - ResponsesApiNamespaceTool::Function(tool) => { - let tool_name = - ToolName::namespaced(namespace.name.clone(), tool.name.clone()); - let code_mode_name = - codex_tools::code_mode_name_for_tool_name(&tool_name); - (code_mode_name == req.tool_name - || tool_name.display() == req.tool_name) - .then_some(tool_name) - } - }) - } - ToolSpec::LocalShell {} - | ToolSpec::ImageGeneration { .. } - | ToolSpec::ToolSearch { .. } - | ToolSpec::WebSearch { .. } - | ToolSpec::Function(_) - | ToolSpec::Freeform(_) => None, - }) - .unwrap_or_else(|| ToolName::plain(req.tool_name.clone())); - let (tool_call_name, payload) = if let Some(tool_info) = exec - .session - .resolve_mcp_tool_info(&requested_tool_name) - .await - { - ( - tool_info.canonical_tool_name(), - crate::tools::context::ToolPayload::Mcp { - server: tool_info.server_name, - tool: tool_info.tool.name.to_string(), - raw_arguments: req.arguments.clone(), - }, - ) - } else if matches!( - router.find_spec(&requested_tool_name), - Some(ToolSpec::Freeform(_)) - ) { - ( - requested_tool_name, - crate::tools::context::ToolPayload::Custom { - input: req.arguments.clone(), - }, - ) - } else { - ( - requested_tool_name, - crate::tools::context::ToolPayload::Function { - arguments: req.arguments.clone(), - }, - ) - }; - - let call = crate::tools::router::ToolCall { - tool_name: tool_call_name, - call_id: req.id.clone(), - payload, - }; - - let session = Arc::clone(&exec.session); - let turn = Arc::clone(&exec.turn); - let cancellation_token = exec.cancellation_token.clone(); - let tracker = Arc::clone(&exec.tracker); - - match router - .dispatch_tool_call_with_code_mode_result( - session, - turn, - cancellation_token, - tracker, - call, - crate::tools::router::ToolCallSource::JsRepl, - ) - .await - { - Ok(result) => { - let response = result.into_response(); - let summary = Self::summarize_tool_call_response(&response); - match serde_json::to_value(response) { - Ok(value) => { - Self::log_tool_call_response( - &req, - /*ok*/ true, - &summary, - Some(&value), - /*error*/ None, - ); - RunToolResult { - id: req.id, - ok: true, - response: Some(value), - error: None, - } - } - Err(err) => { - let error = format!("failed to serialize tool output: {err}"); - let summary = Self::summarize_tool_call_error(&error); - Self::log_tool_call_response( - &req, - /*ok*/ false, - &summary, - /*response*/ None, - Some(&error), - ); - RunToolResult { - id: req.id, - ok: false, - response: None, - error: Some(error), - } - } - } - } - Err(err) => { - let error = err.to_string(); - let summary = Self::summarize_tool_call_error(&error); - Self::log_tool_call_response( - &req, - /*ok*/ false, - &summary, - /*response*/ None, - Some(&error), - ); - RunToolResult { - id: req.id, - ok: false, - response: None, - error: Some(error), - } - } - } - } - - async fn read_stderr( - stderr: tokio::process::ChildStderr, - recent_stderr: Arc>>, - shutdown: CancellationToken, - ) { - let mut reader = BufReader::new(stderr).lines(); - - loop { - let line = tokio::select! { - _ = shutdown.cancelled() => break, - res = reader.next_line() => match res { - Ok(Some(line)) => line, - Ok(None) => break, - Err(err) => { - warn!("js_repl kernel stderr ended: {err}"); - break; - } - }, - }; - let trimmed = line.trim(); - if !trimmed.is_empty() { - let bounded_line = { - let mut tail = recent_stderr.lock().await; - push_stderr_tail_line(&mut tail, trimmed) - }; - if bounded_line.is_empty() { - continue; - } - warn!("js_repl stderr: {bounded_line}"); - } - } - } -} - -fn emitted_image_content_item( - turn: &TurnContext, - image_url: String, - detail: Option, -) -> FunctionCallOutputContentItem { - FunctionCallOutputContentItem::InputImage { - image_url, - detail: normalize_output_image_detail(&turn.model_info, detail) - .or(Some(DEFAULT_IMAGE_DETAIL)), - } -} - -fn validate_emitted_image_url(image_url: &str) -> Result<(), String> { - if !image_url - .get(..5) - .is_some_and(|scheme| scheme.eq_ignore_ascii_case("data:")) - { - return Err("codex.emitImage only accepts data URLs".to_string()); - } - - let media_type = image_url - .split_once(',') - .and_then(|(header, _)| header.get(5..)) - .and_then(|header| header.split(';').next()) - .filter(|media_type| !media_type.is_empty()) - .ok_or_else(|| "codex.emitImage expected a valid image data URL".to_string())?; - - if matches!( - media_type.to_ascii_lowercase().as_str(), - "image/png" | "image/jpeg" | "image/webp" | "image/gif" - ) { - Ok(()) - } else { - Err( - "codex.emitImage only supports image/png, image/jpeg, image/webp, or image/gif" - .to_string(), - ) - } -} - -fn build_exec_result_content_items( - output: String, - content_items: Vec, -) -> Vec { - let mut all_content_items = Vec::with_capacity(content_items.len() + 1); - all_content_items.push(FunctionCallOutputContentItem::InputText { text: output }); - all_content_items.extend(content_items); - all_content_items -} - -fn split_exec_result_content_items( - mut content_items: Vec, -) -> (String, Vec) { - match content_items.first() { - Some(FunctionCallOutputContentItem::InputText { .. }) => { - let FunctionCallOutputContentItem::InputText { text } = content_items.remove(0) else { - unreachable!("first content item should be input_text"); - }; - (text, content_items) - } - Some(FunctionCallOutputContentItem::InputImage { .. }) | None => { - (String::new(), content_items) - } - } -} - -fn is_js_repl_internal_tool(name: &str) -> bool { - matches!(name, "js_repl" | "js_repl_reset") -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum KernelToHost { - ExecResult { - id: String, - ok: bool, - output: String, - #[serde(default)] - error: Option, - }, - RunTool(RunToolRequest), - EmitImage(EmitImageRequest), -} - -#[derive(Clone, Debug, Serialize)] -#[serde(tag = "type", rename_all = "snake_case")] -enum HostToKernel { - Exec { - id: String, - code: String, - #[serde(default)] - timeout_ms: Option, - }, - RunToolResult(RunToolResult), - EmitImageResult(EmitImageResult), -} - -#[derive(Clone, Debug, Deserialize)] -struct RunToolRequest { - id: String, - exec_id: String, - tool_name: String, - arguments: String, -} - -#[derive(Clone, Debug, Serialize)] -struct RunToolResult { - id: String, - ok: bool, - #[serde(default)] - response: Option, - #[serde(default)] - error: Option, -} - -#[derive(Clone, Debug, Deserialize)] -struct EmitImageRequest { - id: String, - exec_id: String, - image_url: String, - #[serde(default)] - detail: Option, -} - -#[derive(Clone, Debug, Serialize)] -struct EmitImageResult { - id: String, - ok: bool, - #[serde(default)] - error: Option, -} - -#[derive(Debug)] -enum ExecResultMessage { - Ok { - content_items: Vec, - }, - Err { - message: String, - }, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] -struct NodeVersion { - major: u64, - minor: u64, - patch: u64, -} - -impl fmt::Display for NodeVersion { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}.{}.{}", self.major, self.minor, self.patch) - } -} - -impl NodeVersion { - fn parse(input: &str) -> Result { - let trimmed = input.trim().trim_start_matches('v'); - let mut parts = trimmed.split(['.', '-', '+']); - let major = parts - .next() - .ok_or_else(|| "missing major version".to_string())? - .parse::() - .map_err(|err| format!("invalid major version: {err}"))?; - let minor = parts - .next() - .ok_or_else(|| "missing minor version".to_string())? - .parse::() - .map_err(|err| format!("invalid minor version: {err}"))?; - let patch = parts - .next() - .ok_or_else(|| "missing patch version".to_string())? - .parse::() - .map_err(|err| format!("invalid patch version: {err}"))?; - Ok(Self { - major, - minor, - patch, - }) - } -} - -fn required_node_version() -> Result { - NodeVersion::parse(JS_REPL_MIN_NODE_VERSION) -} - -async fn read_node_version(node_path: &Path) -> Result { - let output = tokio::process::Command::new(node_path) - .arg("--version") - .output() - .await - .map_err(|err| format!("failed to execute Node: {err}"))?; - - if !output.status.success() { - let mut details = String::new(); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = stdout.trim(); - let stderr = stderr.trim(); - if !stdout.is_empty() { - details.push_str(" stdout: "); - details.push_str(stdout); - } - if !stderr.is_empty() { - details.push_str(" stderr: "); - details.push_str(stderr); - } - let details = if details.is_empty() { - String::new() - } else { - format!(" ({details})") - }; - return Err(format!( - "failed to read Node version (status {status}){details}", - status = output.status - )); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - let stdout = stdout.trim(); - NodeVersion::parse(stdout) - .map_err(|err| format!("failed to parse Node version output `{stdout}`: {err}")) -} - -async fn ensure_node_version(node_path: &Path) -> Result<(), String> { - let required = required_node_version()?; - let found = read_node_version(node_path).await?; - if found < required { - return Err(format!( - "Node runtime too old for js_repl (resolved {node_path}): found v{found}, requires >= v{required}. Install/update Node or set js_repl_node_path to a newer runtime.", - node_path = node_path.display() - )); - } - Ok(()) -} - -pub(crate) async fn resolve_compatible_node(config_path: Option<&Path>) -> Result { - let node_path = resolve_node(config_path).ok_or_else(|| { - "Node runtime not found; install Node or set CODEX_JS_REPL_NODE_PATH".to_string() - })?; - ensure_node_version(&node_path).await?; - Ok(node_path) -} - -pub(crate) fn resolve_node(config_path: Option<&Path>) -> Option { - if let Some(path) = std::env::var_os("CODEX_JS_REPL_NODE_PATH") { - let p = PathBuf::from(path); - if p.exists() { - return Some(p); - } - } - - if let Some(path) = config_path - && path.exists() - { - return Some(path.to_path_buf()); - } - - if let Ok(path) = which::which("node") { - return Some(path); - } - - None -} - -#[cfg(test)] -#[path = "mod_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/tools/js_repl/mod_tests.rs b/codex-rs/core/src/tools/js_repl/mod_tests.rs deleted file mode 100644 index 38bd71e1a3..0000000000 --- a/codex-rs/core/src/tools/js_repl/mod_tests.rs +++ /dev/null @@ -1,2912 +0,0 @@ -use super::*; -use crate::session::tests::make_session_and_context; -use crate::session::tests::make_session_and_context_with_dynamic_tools_and_rx; -use crate::turn_diff_tracker::TurnDiffTracker; -use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem; -use codex_protocol::dynamic_tools::DynamicToolResponse; -use codex_protocol::dynamic_tools::DynamicToolSpec; -use codex_protocol::models::DEFAULT_IMAGE_DETAIL; -use codex_protocol::models::FunctionCallOutputContentItem; -use codex_protocol::models::FunctionCallOutputPayload; -use codex_protocol::models::ImageDetail; -use codex_protocol::models::ResponseInputItem; -use codex_protocol::openai_models::InputModality; -use codex_protocol::permissions::FileSystemSandboxPolicy; -use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::SandboxPolicy; -use core_test_support::PathBufExt; -use core_test_support::TempDirExt; -use pretty_assertions::assert_eq; -use std::fs; -use std::path::Path; -use tempfile::tempdir; - -fn set_danger_full_access(turn: &mut crate::session::turn_context::TurnContext) { - turn.sandbox_policy - .set(SandboxPolicy::DangerFullAccess) - .expect("test setup should allow updating sandbox policy"); - turn.file_system_sandbox_policy = FileSystemSandboxPolicy::from(turn.sandbox_policy.get()); - turn.network_sandbox_policy = NetworkSandboxPolicy::from(turn.sandbox_policy.get()); -} - -#[test] -fn node_version_parses_v_prefix_and_suffix() { - let version = NodeVersion::parse("v25.1.0-nightly.2024").unwrap(); - assert_eq!( - version, - NodeVersion { - major: 25, - minor: 1, - patch: 0, - } - ); -} - -#[test] -fn truncate_utf8_prefix_by_bytes_preserves_character_boundaries() { - let input = "aé🙂z"; - assert_eq!(truncate_utf8_prefix_by_bytes(input, /*max_bytes*/ 0), ""); - assert_eq!(truncate_utf8_prefix_by_bytes(input, /*max_bytes*/ 1), "a"); - assert_eq!(truncate_utf8_prefix_by_bytes(input, /*max_bytes*/ 2), "a"); - assert_eq!(truncate_utf8_prefix_by_bytes(input, /*max_bytes*/ 3), "aé"); - assert_eq!(truncate_utf8_prefix_by_bytes(input, /*max_bytes*/ 6), "aé"); - assert_eq!( - truncate_utf8_prefix_by_bytes(input, /*max_bytes*/ 7), - "aé🙂" - ); - assert_eq!( - truncate_utf8_prefix_by_bytes(input, /*max_bytes*/ 8), - "aé🙂z" - ); -} - -#[test] -fn stderr_tail_applies_line_and_byte_limits() { - let mut lines = VecDeque::new(); - let per_line_cap = JS_REPL_STDERR_TAIL_LINE_MAX_BYTES.min(JS_REPL_STDERR_TAIL_MAX_BYTES); - let long = "x".repeat(per_line_cap + 128); - let bounded = push_stderr_tail_line(&mut lines, &long); - assert_eq!(bounded.len(), per_line_cap); - - for i in 0..50 { - let line = format!("line-{i}-{}", "y".repeat(200)); - push_stderr_tail_line(&mut lines, &line); - } - - assert!(lines.len() <= JS_REPL_STDERR_TAIL_LINE_LIMIT); - assert!(lines.iter().all(|line| line.len() <= per_line_cap)); - assert!(stderr_tail_formatted_bytes(&lines) <= JS_REPL_STDERR_TAIL_MAX_BYTES); - assert_eq!( - format_stderr_tail(&lines).len(), - stderr_tail_formatted_bytes(&lines) - ); -} - -#[test] -fn model_kernel_failure_details_are_structured_and_truncated() { - let snapshot = KernelDebugSnapshot { - pid: Some(42), - status: "exited(code=1)".to_string(), - stderr_tail: "s".repeat(JS_REPL_MODEL_DIAG_STDERR_MAX_BYTES + 400), - }; - let stream_error = "e".repeat(JS_REPL_MODEL_DIAG_ERROR_MAX_BYTES + 200); - let message = with_model_kernel_failure_message( - "js_repl kernel exited unexpectedly", - "stdout_eof", - Some(&stream_error), - &snapshot, - ); - assert!(message.starts_with("js_repl kernel exited unexpectedly\n\njs_repl diagnostics: ")); - let (_prefix, encoded) = message - .split_once("js_repl diagnostics: ") - .expect("diagnostics suffix should be present"); - let parsed: serde_json::Value = - serde_json::from_str(encoded).expect("diagnostics should be valid json"); - assert_eq!( - parsed.get("reason").and_then(|v| v.as_str()), - Some("stdout_eof") - ); - assert_eq!( - parsed.get("kernel_pid").and_then(serde_json::Value::as_u64), - Some(42) - ); - assert_eq!( - parsed.get("kernel_status").and_then(|v| v.as_str()), - Some("exited(code=1)") - ); - assert!( - parsed - .get("kernel_stderr_tail") - .and_then(|v| v.as_str()) - .expect("kernel_stderr_tail should be present") - .len() - <= JS_REPL_MODEL_DIAG_STDERR_MAX_BYTES - ); - assert!( - parsed - .get("stream_error") - .and_then(|v| v.as_str()) - .expect("stream_error should be present") - .len() - <= JS_REPL_MODEL_DIAG_ERROR_MAX_BYTES - ); -} - -#[test] -fn write_error_diagnostics_only_attach_for_likely_kernel_failures() { - let running = KernelDebugSnapshot { - pid: Some(7), - status: "running".to_string(), - stderr_tail: "".to_string(), - }; - let exited = KernelDebugSnapshot { - pid: Some(7), - status: "exited(code=1)".to_string(), - stderr_tail: "".to_string(), - }; - assert!(!should_include_model_diagnostics_for_write_error( - "failed to flush kernel message: other io error", - &running - )); - assert!(should_include_model_diagnostics_for_write_error( - "failed to write to kernel: Broken pipe (os error 32)", - &running - )); - assert!(should_include_model_diagnostics_for_write_error( - "failed to write to kernel: some other io error", - &exited - )); -} - -#[test] -fn js_repl_internal_tool_guard_matches_expected_names() { - assert!(is_js_repl_internal_tool("js_repl")); - assert!(is_js_repl_internal_tool("js_repl_reset")); - assert!(!is_js_repl_internal_tool("shell_command")); - assert!(!is_js_repl_internal_tool("list_mcp_resources")); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn wait_for_exec_tool_calls_map_drains_inflight_calls_without_hanging() { - let exec_tool_calls = Arc::new(Mutex::new(HashMap::new())); - - for _ in 0..128 { - let exec_id = Uuid::new_v4().to_string(); - exec_tool_calls - .lock() - .await - .insert(exec_id.clone(), ExecToolCalls::default()); - assert!( - JsReplManager::begin_exec_tool_call(&exec_tool_calls, &exec_id) - .await - .is_some() - ); - - let wait_map = Arc::clone(&exec_tool_calls); - let wait_exec_id = exec_id.clone(); - let waiter = tokio::spawn(async move { - JsReplManager::wait_for_exec_tool_calls_map(&wait_map, &wait_exec_id).await; - }); - - let finish_map = Arc::clone(&exec_tool_calls); - let finish_exec_id = exec_id.clone(); - let finisher = tokio::spawn(async move { - tokio::task::yield_now().await; - JsReplManager::finish_exec_tool_call(&finish_map, &finish_exec_id).await; - }); - - tokio::time::timeout(Duration::from_secs(1), waiter) - .await - .expect("wait_for_exec_tool_calls_map should not hang") - .expect("wait task should not panic"); - finisher.await.expect("finish task should not panic"); - - JsReplManager::clear_exec_tool_calls_map(&exec_tool_calls, &exec_id).await; - } -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn reset_waits_for_exec_lock_before_clearing_exec_tool_calls() { - let manager = JsReplManager::new(/*node_path*/ None, Vec::new()) - .await - .expect("manager should initialize"); - let permit = manager - .exec_lock - .clone() - .acquire_owned() - .await - .expect("lock should be acquirable"); - let exec_id = Uuid::new_v4().to_string(); - manager.register_exec_tool_calls(&exec_id).await; - - let reset_manager = Arc::clone(&manager); - let mut reset_task = tokio::spawn(async move { reset_manager.reset().await }); - tokio::time::sleep(Duration::from_millis(50)).await; - - assert!( - !reset_task.is_finished(), - "reset should wait until execute lock is released" - ); - assert!( - manager.exec_tool_calls.lock().await.contains_key(&exec_id), - "reset must not clear tool-call contexts while execute lock is held" - ); - - drop(permit); - - tokio::time::timeout(Duration::from_secs(1), &mut reset_task) - .await - .expect("reset should complete after execute lock release") - .expect("reset task should not panic") - .expect("reset should succeed"); - assert!( - !manager.exec_tool_calls.lock().await.contains_key(&exec_id), - "reset should clear tool-call contexts after lock acquisition" - ); -} - -#[test] -fn summarize_tool_call_response_for_multimodal_function_output() { - let response = ResponseInputItem::FunctionCallOutput { - call_id: "call-1".to_string(), - output: FunctionCallOutputPayload::from_content_items(vec![ - FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,abcd".to_string(), - detail: Some(DEFAULT_IMAGE_DETAIL), - }, - ]), - }; - - let actual = JsReplManager::summarize_tool_call_response(&response); - - assert_eq!( - actual, - JsReplToolCallResponseSummary { - response_type: Some("function_call_output".to_string()), - payload_kind: Some(JsReplToolCallPayloadKind::FunctionContentItems), - payload_text_preview: None, - payload_text_length: None, - payload_item_count: Some(1), - text_item_count: Some(0), - image_item_count: Some(1), - structured_content_present: None, - result_is_error: None, - } - ); -} - -#[tokio::test] -async fn emitted_image_content_item_preserves_explicit_non_original_detail() { - let (_session, turn) = make_session_and_context().await; - let content_item = emitted_image_content_item( - &turn, - "data:image/png;base64,AAA".to_string(), - Some(ImageDetail::Low), - ); - assert_eq!( - content_item, - FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - detail: Some(ImageDetail::Low), - } - ); -} - -#[tokio::test] -async fn emitted_image_content_item_allows_explicit_original_detail_when_supported() { - let (_session, mut turn) = make_session_and_context().await; - turn.model_info.supports_image_detail_original = true; - - let content_item = emitted_image_content_item( - &turn, - "data:image/png;base64,AAA".to_string(), - Some(ImageDetail::Original), - ); - - assert_eq!( - content_item, - FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - detail: Some(ImageDetail::Original), - } - ); -} - -#[tokio::test] -async fn emitted_image_content_item_defaults_to_high_for_unsupported_original_detail() { - let (_session, turn) = make_session_and_context().await; - - let content_item = emitted_image_content_item( - &turn, - "data:image/png;base64,AAA".to_string(), - Some(ImageDetail::Original), - ); - - assert_eq!( - content_item, - FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), - detail: Some(DEFAULT_IMAGE_DETAIL), - } - ); -} - -#[test] -fn validate_emitted_image_url_accepts_case_insensitive_data_scheme() { - assert_eq!( - validate_emitted_image_url("DATA:image/png;base64,AAA"), - Ok(()) - ); -} - -#[test] -fn validate_emitted_image_url_rejects_non_data_scheme() { - assert_eq!( - validate_emitted_image_url("https://example.com/image.png"), - Err("codex.emitImage only accepts data URLs".to_string()) - ); -} - -#[test] -fn summarize_tool_call_response_for_multimodal_custom_output() { - let response = ResponseInputItem::CustomToolCallOutput { - call_id: "call-1".to_string(), - name: None, - output: FunctionCallOutputPayload::from_content_items(vec![ - FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,abcd".to_string(), - detail: Some(DEFAULT_IMAGE_DETAIL), - }, - ]), - }; - - let actual = JsReplManager::summarize_tool_call_response(&response); - - assert_eq!( - actual, - JsReplToolCallResponseSummary { - response_type: Some("custom_tool_call_output".to_string()), - payload_kind: Some(JsReplToolCallPayloadKind::CustomContentItems), - payload_text_preview: None, - payload_text_length: None, - payload_item_count: Some(1), - text_item_count: Some(0), - image_item_count: Some(1), - structured_content_present: None, - result_is_error: None, - } - ); -} - -#[test] -fn summarize_tool_call_error_marks_error_payload() { - let actual = JsReplManager::summarize_tool_call_error("tool failed"); - - assert_eq!( - actual, - JsReplToolCallResponseSummary { - response_type: None, - payload_kind: Some(JsReplToolCallPayloadKind::Error), - payload_text_preview: Some("tool failed".to_string()), - payload_text_length: Some("tool failed".len()), - payload_item_count: None, - text_item_count: None, - image_item_count: None, - structured_content_present: None, - result_is_error: None, - } - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn reset_clears_inflight_exec_tool_calls_without_waiting() { - let manager = JsReplManager::new(/*node_path*/ None, Vec::new()) - .await - .expect("manager should initialize"); - let exec_id = Uuid::new_v4().to_string(); - manager.register_exec_tool_calls(&exec_id).await; - assert!( - JsReplManager::begin_exec_tool_call(&manager.exec_tool_calls, &exec_id) - .await - .is_some() - ); - - let wait_manager = Arc::clone(&manager); - let wait_exec_id = exec_id.clone(); - let waiter = tokio::spawn(async move { - wait_manager.wait_for_exec_tool_calls(&wait_exec_id).await; - }); - tokio::task::yield_now().await; - - tokio::time::timeout(Duration::from_secs(1), manager.reset()) - .await - .expect("reset should not hang") - .expect("reset should succeed"); - - tokio::time::timeout(Duration::from_secs(1), waiter) - .await - .expect("waiter should be released") - .expect("wait task should not panic"); - - assert!(manager.exec_tool_calls.lock().await.is_empty()); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn reset_aborts_inflight_exec_tool_tasks() { - let manager = JsReplManager::new(/*node_path*/ None, Vec::new()) - .await - .expect("manager should initialize"); - let exec_id = Uuid::new_v4().to_string(); - manager.register_exec_tool_calls(&exec_id).await; - let reset_cancel = JsReplManager::begin_exec_tool_call(&manager.exec_tool_calls, &exec_id) - .await - .expect("exec should be registered"); - - let task = tokio::spawn(async move { - tokio::select! { - _ = reset_cancel.cancelled() => "cancelled", - _ = tokio::time::sleep(Duration::from_secs(60)) => "timed_out", - } - }); - - tokio::time::timeout(Duration::from_secs(1), manager.reset()) - .await - .expect("reset should not hang") - .expect("reset should succeed"); - - let outcome = tokio::time::timeout(Duration::from_secs(1), task) - .await - .expect("cancelled task should resolve promptly") - .expect("task should not panic"); - assert_eq!(outcome, "cancelled"); -} - -async fn can_run_js_repl_runtime_tests() -> bool { - // These white-box runtime tests are required on macOS. Linux relies on - // the codex-linux-sandbox arg0 dispatch path, which is exercised in - // integration tests instead. - cfg!(target_os = "macos") -} -fn write_js_repl_test_package_source(base: &Path, name: &str, source: &str) -> anyhow::Result<()> { - let pkg_dir = base.join("node_modules").join(name); - fs::create_dir_all(&pkg_dir)?; - fs::write( - pkg_dir.join("package.json"), - format!( - "{{\n \"name\": \"{name}\",\n \"version\": \"1.0.0\",\n \"type\": \"module\",\n \"exports\": {{\n \"import\": \"./index.js\"\n }}\n}}\n" - ), - )?; - fs::write(pkg_dir.join("index.js"), source)?; - Ok(()) -} - -fn write_js_repl_test_package(base: &Path, name: &str, value: &str) -> anyhow::Result<()> { - write_js_repl_test_package_source(base, name, &format!("export const value = \"{value}\";\n"))?; - Ok(()) -} - -fn write_js_repl_test_module(base: &Path, relative: &str, contents: &str) -> anyhow::Result<()> { - let module_path = base.join(relative); - if let Some(parent) = module_path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(module_path, contents)?; - Ok(()) -} - -#[tokio::test] -async fn js_repl_timeout_does_not_deadlock() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let result = tokio::time::timeout( - Duration::from_secs(3), - manager.execute( - session, - turn, - tracker, - JsReplArgs { - code: "while (true) {}".to_string(), - timeout_ms: Some(50), - }, - ), - ) - .await - .expect("execute should return, not deadlock") - .expect_err("expected timeout error"); - - assert_eq!( - result.to_string(), - "js_repl execution timed out; kernel reset, rerun your request" - ); - Ok(()) -} - -#[tokio::test] -async fn js_repl_timeout_kills_kernel_process() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - manager - .execute( - Arc::clone(&session), - Arc::clone(&turn), - Arc::clone(&tracker), - JsReplArgs { - code: "console.log('warmup');".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - - let child = { - let guard = manager.kernel.lock().await; - let state = guard.as_ref().expect("kernel should exist after warmup"); - Arc::clone(&state.child) - }; - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "while (true) {}".to_string(), - timeout_ms: Some(50), - }, - ) - .await - .expect_err("expected timeout error"); - - assert_eq!( - result.to_string(), - "js_repl execution timed out; kernel reset, rerun your request" - ); - - let exit_state = { - let mut child = child.lock().await; - child.try_wait()? - }; - assert!( - exit_state.is_some(), - "timed out js_repl execution should kill previous kernel process" - ); - Ok(()) -} - -#[tokio::test] -async fn interrupt_turn_exec_clears_matching_submitted_exec() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let manager = JsReplManager::new(/*node_path*/ None, Vec::new()) - .await - .expect("manager should initialize"); - let (_session, turn) = make_session_and_context().await; - let turn = Arc::new(turn); - let dependency_env = HashMap::new(); - let mut state = manager - .start_kernel(Arc::clone(&turn), &dependency_env, /*thread_id*/ None) - .await - .map_err(anyhow::Error::msg)?; - let child = Arc::clone(&state.child); - state.top_level_exec_state = TopLevelExecState::Submitted { - turn_id: turn.sub_id.clone(), - exec_id: "exec-1".to_string(), - }; - *manager.kernel.lock().await = Some(state); - manager.register_exec_tool_calls("exec-1").await; - - assert!(manager.interrupt_turn_exec(&turn.sub_id).await?); - assert!(manager.kernel.lock().await.is_none()); - assert!(manager.exec_tool_calls.lock().await.is_empty()); - - tokio::time::timeout(Duration::from_secs(3), async { - loop { - let exited = { - let mut child = child.lock().await; - child.try_wait()?.is_some() - }; - if exited { - return Ok::<(), anyhow::Error>(()); - } - tokio::time::sleep(Duration::from_millis(25)).await; - } - }) - .await - .expect("kernel should exit after interrupt cleanup")?; - - Ok(()) -} - -#[tokio::test] -async fn interrupt_turn_exec_resets_matching_pending_kernel_start() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let manager = JsReplManager::new(/*node_path*/ None, Vec::new()) - .await - .expect("manager should initialize"); - let (_session, turn) = make_session_and_context().await; - let turn = Arc::new(turn); - let dependency_env = HashMap::new(); - let mut state = manager - .start_kernel(Arc::clone(&turn), &dependency_env, /*thread_id*/ None) - .await - .map_err(anyhow::Error::msg)?; - state.top_level_exec_state = TopLevelExecState::FreshKernel { - turn_id: turn.sub_id.clone(), - exec_id: None, - }; - let child = Arc::clone(&state.child); - *manager.kernel.lock().await = Some(state); - - assert!(manager.interrupt_turn_exec(&turn.sub_id).await?); - assert!(manager.kernel.lock().await.is_none()); - - tokio::time::timeout(Duration::from_secs(3), async { - loop { - let exited = { - let mut child = child.lock().await; - child.try_wait()?.is_some() - }; - if exited { - return Ok::<(), anyhow::Error>(()); - } - tokio::time::sleep(Duration::from_millis(25)).await; - } - }) - .await - .expect("kernel should exit after interrupt cleanup")?; - - Ok(()) -} - -#[tokio::test] -async fn interrupt_turn_exec_does_not_reset_reused_kernel_before_submit() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let manager = JsReplManager::new(/*node_path*/ None, Vec::new()) - .await - .expect("manager should initialize"); - let (_session, turn) = make_session_and_context().await; - let turn = Arc::new(turn); - let dependency_env = HashMap::new(); - let mut state = manager - .start_kernel(Arc::clone(&turn), &dependency_env, /*thread_id*/ None) - .await - .map_err(anyhow::Error::msg)?; - state.top_level_exec_state = TopLevelExecState::ReusedKernelPending { - turn_id: turn.sub_id.clone(), - exec_id: "exec-1".to_string(), - }; - *manager.kernel.lock().await = Some(state); - - assert!(!manager.interrupt_turn_exec(&turn.sub_id).await?); - assert!(manager.kernel.lock().await.is_some()); - - manager.reset().await.map_err(anyhow::Error::msg) -} - -#[tokio::test] -async fn interrupt_active_exec_stops_aborted_kernel_before_later_exec() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let dir = tempdir()?; - let (session, mut turn) = make_session_and_context().await; - turn.cwd = dir.abs(); - set_danger_full_access(&mut turn); - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let first_file = dir.path().join("1.txt"); - let second_file = dir.path().join("2.txt"); - let first_file_js = serde_json::to_string(&first_file.to_string_lossy().to_string())?; - let second_file_js = serde_json::to_string(&second_file.to_string_lossy().to_string())?; - let code = format!( - r#" -const {{ promises: fs }} = await import("fs"); - -const paths = [{first_file_js}, {second_file_js}]; -for (let i = 0; i < paths.length; i++) {{ - await fs.writeFile(paths[i], `${{i + 1}}`); - if (i + 1 < paths.length) {{ - await new Promise((resolve) => setTimeout(resolve, 1000)); - }} -}} -"# - ); - - let handle = tokio::spawn({ - let manager = Arc::clone(&manager); - let session = Arc::clone(&session); - let turn = Arc::clone(&turn); - let tracker = Arc::clone(&tracker); - async move { - manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code, - timeout_ms: Some(15_000), - }, - ) - .await - } - }); - - tokio::time::timeout(Duration::from_secs(3), async { - while !first_file.exists() { - tokio::time::sleep(Duration::from_millis(25)).await; - } - }) - .await - .expect("first file should be written before interrupt"); - - let child = { - let guard = manager.kernel.lock().await; - let state = guard - .as_ref() - .expect("kernel should exist while exec is running"); - Arc::clone(&state.child) - }; - - handle.abort(); - assert!(manager.interrupt_turn_exec(&turn.sub_id).await?); - - tokio::time::timeout(Duration::from_secs(3), async { - loop { - let exited = { - let mut child = child.lock().await; - child.try_wait()?.is_some() - }; - if exited { - return Ok::<(), anyhow::Error>(()); - } - tokio::time::sleep(Duration::from_millis(25)).await; - } - }) - .await - .expect("kernel should exit after interrupt")?; - - tokio::time::sleep(Duration::from_millis(1500)).await; - assert!(first_file.exists()); - assert!(!second_file.exists()); - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "console.log('after interrupt');".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(result.output.contains("after interrupt")); - - Ok(()) -} - -#[tokio::test] -async fn js_repl_forced_kernel_exit_recovers_on_next_exec() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - manager - .execute( - Arc::clone(&session), - Arc::clone(&turn), - Arc::clone(&tracker), - JsReplArgs { - code: "console.log('warmup');".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - - let child = { - let guard = manager.kernel.lock().await; - let state = guard.as_ref().expect("kernel should exist after warmup"); - Arc::clone(&state.child) - }; - JsReplManager::kill_kernel_child(&child, "test_crash").await; - tokio::time::timeout(Duration::from_secs(1), async { - loop { - let cleared = { - let guard = manager.kernel.lock().await; - guard - .as_ref() - .is_none_or(|state| !Arc::ptr_eq(&state.child, &child)) - }; - if cleared { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("host should clear dead kernel state promptly"); - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "console.log('after-kill');".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(result.output.contains("after-kill")); - Ok(()) -} - -#[tokio::test] -async fn js_repl_uncaught_exception_returns_exec_error_and_recovers() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = crate::session::tests::make_session_and_context().await; - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - manager - .execute( - Arc::clone(&session), - Arc::clone(&turn), - Arc::clone(&tracker), - JsReplArgs { - code: "console.log('warmup');".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - - let child = { - let guard = manager.kernel.lock().await; - let state = guard.as_ref().expect("kernel should exist after warmup"); - Arc::clone(&state.child) - }; - - let err = tokio::time::timeout( - Duration::from_secs(3), - manager.execute( - Arc::clone(&session), - Arc::clone(&turn), - Arc::clone(&tracker), - JsReplArgs { - code: "setTimeout(() => { throw new Error('boom'); }, 0);\nawait new Promise(() => {});".to_string(), - timeout_ms: Some(10_000), - }, - ), - ) - .await - .expect("uncaught exception should fail promptly") - .expect_err("expected uncaught exception to fail the exec"); - - let message = err.to_string(); - assert!(message.contains("js_repl kernel uncaught exception: boom")); - assert!(message.contains("kernel reset.")); - assert!(message.contains("Catch or handle async errors")); - assert!(!message.contains("js_repl kernel exited unexpectedly")); - - tokio::time::timeout(Duration::from_secs(1), async { - loop { - let exited = { - let mut child = child.lock().await; - child.try_wait()?.is_some() - }; - if exited { - return Ok::<(), anyhow::Error>(()); - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("uncaught exception should terminate the previous kernel process")?; - - tokio::time::timeout(Duration::from_secs(1), async { - loop { - let cleared = { - let guard = manager.kernel.lock().await; - guard - .as_ref() - .is_none_or(|state| !Arc::ptr_eq(&state.child, &child)) - }; - if cleared { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("host should clear dead kernel state promptly"); - - let next = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "console.log('after reset');".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(next.output.contains("after reset")); - Ok(()) -} - -#[tokio::test] -async fn js_repl_waits_for_unawaited_tool_calls_before_completion() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, mut turn) = make_session_and_context().await; - turn.approval_policy - .set(AskForApproval::Never) - .expect("test setup should allow updating approval policy"); - set_danger_full_access(&mut turn); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let marker = turn - .cwd - .join(format!("js-repl-unawaited-marker-{}.txt", Uuid::new_v4())); - let marker_json = serde_json::to_string(&marker.to_string_lossy().to_string())?; - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: format!( - r#" -const marker = {marker_json}; -void codex.tool("shell_command", {{ command: `sleep 0.35; printf js_repl_unawaited_done > "${{marker}}"` }}); -console.log("cell-complete"); -"# - ), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(result.output.contains("cell-complete")); - let marker_contents = tokio::fs::read_to_string(&marker).await?; - assert_eq!(marker_contents, "js_repl_unawaited_done"); - let _ = tokio::fs::remove_file(&marker).await; - Ok(()) -} - -#[tokio::test] -async fn js_repl_persisted_tool_helpers_work_across_cells() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, mut turn) = make_session_and_context().await; - turn.approval_policy - .set(AskForApproval::Never) - .expect("test setup should allow updating approval policy"); - set_danger_full_access(&mut turn); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let global_marker = turn - .cwd - .join(format!("js-repl-global-helper-{}.txt", Uuid::new_v4())); - let lexical_marker = turn - .cwd - .join(format!("js-repl-lexical-helper-{}.txt", Uuid::new_v4())); - let global_marker_json = serde_json::to_string(&global_marker.to_string_lossy().to_string())?; - let lexical_marker_json = serde_json::to_string(&lexical_marker.to_string_lossy().to_string())?; - - manager - .execute( - Arc::clone(&session), - Arc::clone(&turn), - Arc::clone(&tracker), - JsReplArgs { - code: format!( - r#" -const globalMarker = {global_marker_json}; -const lexicalMarker = {lexical_marker_json}; -const savedTool = codex.tool; -globalThis.globalToolHelper = {{ - run: () => savedTool("shell_command", {{ command: `printf global_helper > "${{globalMarker}}"` }}), -}}; -const lexicalToolHelper = {{ - run: () => savedTool("shell_command", {{ command: `printf lexical_helper > "${{lexicalMarker}}"` }}), -}}; -"# - ), - timeout_ms: Some(10_000), - }, - ) - .await?; - - let next = manager - .execute( - Arc::clone(&session), - Arc::clone(&turn), - tracker, - JsReplArgs { - code: r#" -await globalToolHelper.run(); -await lexicalToolHelper.run(); -console.log("helpers-ran"); -"# - .to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - - assert!(next.output.contains("helpers-ran")); - assert_eq!( - tokio::fs::read_to_string(&global_marker).await?, - "global_helper" - ); - assert_eq!( - tokio::fs::read_to_string(&lexical_marker).await?, - "lexical_helper" - ); - let _ = tokio::fs::remove_file(&global_marker).await; - let _ = tokio::fs::remove_file(&lexical_marker).await; - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_does_not_auto_attach_image_via_view_image_tool() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, mut turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - turn.approval_policy - .set(AskForApproval::Never) - .expect("test setup should allow updating approval policy"); - set_danger_full_access(&mut turn); - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -const fs = await import("node:fs/promises"); -const path = await import("node:path"); -const imagePath = path.join(codex.tmpDir, "js-repl-view-image.png"); -const png = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", - "base64" -); -await fs.writeFile(imagePath, png); -const out = await codex.tool("view_image", { path: imagePath }); -console.log(out.type); -"#; - - let result = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await?; - assert!(result.output.contains("function_call_output")); - assert!(result.content_items.is_empty()); - assert!(session.get_pending_input().await.is_empty()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_can_emit_image_via_view_image_tool() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, mut turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - turn.approval_policy - .set(AskForApproval::Never) - .expect("test setup should allow updating approval policy"); - set_danger_full_access(&mut turn); - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -const fs = await import("node:fs/promises"); -const path = await import("node:path"); -const imagePath = path.join(codex.tmpDir, "js-repl-view-image-explicit.png"); -const png = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", - "base64" -); -await fs.writeFile(imagePath, png); -const out = await codex.tool("view_image", { path: imagePath }); -await codex.emitImage(out); -console.log(out.type); -"#; - - let result = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await?; - assert!(result.output.contains("function_call_output")); - assert_eq!( - result.content_items.as_slice(), - [FunctionCallOutputContentItem::InputImage { - image_url: - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" - .to_string(), - detail: Some(DEFAULT_IMAGE_DETAIL), - }] - .as_slice() - ); - assert!(session.get_pending_input().await.is_empty()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_can_emit_image_from_bytes_and_mime_type() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -const png = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", - "base64" -); -await codex.emitImage({ bytes: png, mimeType: "image/png" }); -"#; - - let result = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await?; - assert_eq!( - result.content_items.as_slice(), - [FunctionCallOutputContentItem::InputImage { - image_url: - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" - .to_string(), - detail: Some(DEFAULT_IMAGE_DETAIL), - }] - .as_slice() - ); - assert!(session.get_pending_input().await.is_empty()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_can_emit_multiple_images_in_one_cell() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -await codex.emitImage( - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" -); -await codex.emitImage( - "data:image/gif;base64,R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=" -); -"#; - - let result = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await?; - assert_eq!( - result.content_items.as_slice(), - [ - FunctionCallOutputContentItem::InputImage { - image_url: - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" - .to_string(), - detail: Some(DEFAULT_IMAGE_DETAIL), - }, - FunctionCallOutputContentItem::InputImage { - image_url: - "data:image/gif;base64,R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs=" - .to_string(), - detail: Some(DEFAULT_IMAGE_DETAIL), - }, - ] - .as_slice() - ); - assert!(session.get_pending_input().await.is_empty()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_waits_for_unawaited_emit_image_before_completion() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -void codex.emitImage( - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" -); -console.log("cell-complete"); -"#; - - let result = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await?; - assert!(result.output.contains("cell-complete")); - assert_eq!( - result.content_items.as_slice(), - [FunctionCallOutputContentItem::InputImage { - image_url: - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" - .to_string(), - detail: Some(DEFAULT_IMAGE_DETAIL), - }] - .as_slice() - ); - assert!(session.get_pending_input().await.is_empty()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_persisted_emit_image_helpers_work_across_cells() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let data_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; - - manager - .execute( - Arc::clone(&session), - Arc::clone(&turn), - Arc::clone(&tracker), - JsReplArgs { - code: format!( - r#" -const dataUrl = "{data_url}"; -const savedEmitImage = codex.emitImage; -globalThis.globalEmitHelper = {{ - run: () => savedEmitImage(dataUrl), -}}; -const lexicalEmitHelper = {{ - run: () => savedEmitImage(dataUrl), -}}; -"# - ), - timeout_ms: Some(15_000), - }, - ) - .await?; - - let next = manager - .execute( - Arc::clone(&session), - Arc::clone(&turn), - tracker, - JsReplArgs { - code: r#" -await globalEmitHelper.run(); -await lexicalEmitHelper.run(); -console.log("helpers-ran"); -"# - .to_string(), - timeout_ms: Some(15_000), - }, - ) - .await?; - - assert!(next.output.contains("helpers-ran")); - assert_eq!( - next.content_items, - vec![ - FunctionCallOutputContentItem::InputImage { - image_url: data_url.to_string(), - detail: Some(DEFAULT_IMAGE_DETAIL), - }, - FunctionCallOutputContentItem::InputImage { - image_url: data_url.to_string(), - detail: Some(DEFAULT_IMAGE_DETAIL), - }, - ] - ); - assert!(session.get_pending_input().await.is_empty()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_unawaited_emit_image_errors_fail_cell() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -void codex.emitImage({ bytes: new Uint8Array(), mimeType: "image/png" }); -console.log("cell-complete"); -"#; - - let err = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await - .expect_err("unawaited invalid emitImage should fail"); - assert!(err.to_string().contains("expected non-empty bytes")); - assert!(session.get_pending_input().await.is_empty()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_caught_emit_image_error_does_not_fail_cell() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -try { - await codex.emitImage({ bytes: new Uint8Array(), mimeType: "image/png" }); -} catch (error) { - console.log(error.message); -} -console.log("cell-complete"); -"#; - - let result = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await?; - assert!(result.output.contains("expected non-empty bytes")); - assert!(result.output.contains("cell-complete")); - assert!(result.content_items.is_empty()); - assert!(session.get_pending_input().await.is_empty()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_emit_image_requires_explicit_mime_type_for_bytes() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -const png = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", - "base64" -); -await codex.emitImage({ bytes: png }); -"#; - - let err = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await - .expect_err("missing mimeType should fail"); - assert!(err.to_string().contains("expected a non-empty mimeType")); - assert!(session.get_pending_input().await.is_empty()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_emit_image_rejects_unsupported_byte_mime_type() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -await codex.emitImage({ - bytes: Buffer.from([255, 0, 0, 255]), - mimeType: "image/rgba", -}); -"#; - - let err = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await - .expect_err("unsupported byte MIME type should fail"); - assert!( - err.to_string() - .contains("only supports image/png, image/jpeg, image/webp, or image/gif") - ); - assert!(session.get_pending_input().await.is_empty()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_emit_image_rejects_non_data_url() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -await codex.emitImage("https://example.com/image.png"); -"#; - - let err = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await - .expect_err("non-data URLs should fail"); - assert!(err.to_string().contains("only accepts data URLs")); - assert!(session.get_pending_input().await.is_empty()); - - Ok(()) -} - -#[test] -fn validate_emitted_image_url_rejects_unsupported_mime_type() { - assert_eq!( - validate_emitted_image_url("data:image/rgba;base64,AAAA").expect_err("unsupported MIME"), - "codex.emitImage only supports image/png, image/jpeg, image/webp, or image/gif" - ); -} - -#[test] -fn validate_emitted_image_url_accepts_supported_mime_type_case_insensitive() { - assert!(validate_emitted_image_url("DATA:image/PNG;base64,AAAA").is_ok()); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_emit_image_accepts_case_insensitive_data_url() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -await codex.emitImage("DATA:image/png;base64,AAA"); -"#; - - let result = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await?; - assert_eq!( - result.content_items.as_slice(), - [FunctionCallOutputContentItem::InputImage { - image_url: "DATA:image/png;base64,AAA".to_string(), - detail: Some(DEFAULT_IMAGE_DETAIL), - }] - .as_slice() - ); - assert!(session.get_pending_input().await.is_empty()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_emit_image_rejects_invalid_detail() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -const png = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", - "base64" -); -await codex.emitImage({ bytes: png, mimeType: "image/png", detail: "ultra" }); -"#; - - let err = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await - .expect_err("invalid detail should fail"); - assert!(err.to_string().contains("expected detail to be one of")); - assert!(session.get_pending_input().await.is_empty()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_emit_image_treats_null_detail_as_omitted() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - let session = Arc::new(session); - let turn = Arc::new(turn); - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -const png = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", - "base64" -); -await codex.emitImage({ bytes: png, mimeType: "image/png", detail: null }); -"#; - - let result = manager - .execute( - Arc::clone(&session), - turn, - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ) - .await?; - assert_eq!( - result.content_items.as_slice(), - [FunctionCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(), - detail: Some(DEFAULT_IMAGE_DETAIL), - }] - .as_slice() - ); - assert!(session.get_pending_input().await.is_empty()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_emit_image_rejects_mixed_content() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn, rx_event) = - make_session_and_context_with_dynamic_tools_and_rx(vec![DynamicToolSpec { - namespace: None, - name: "inline_image".to_string(), - description: "Returns inline text and image content.".to_string(), - input_schema: serde_json::json!({ - "type": "object", - "properties": {}, - "additionalProperties": false - }), - defer_loading: false, - }]) - .await; - if !turn - .model_info - .input_modalities - .contains(&InputModality::Image) - { - return Ok(()); - } - - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -const out = await codex.tool("inline_image", {}); -await codex.emitImage(out); -"#; - let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; - - let session_for_response = Arc::clone(&session); - let response_watcher = async move { - loop { - let event = tokio::time::timeout(Duration::from_secs(2), rx_event.recv()).await??; - if let EventMsg::DynamicToolCallRequest(request) = event.msg { - session_for_response - .notify_dynamic_tool_response( - &request.call_id, - DynamicToolResponse { - content_items: vec![ - DynamicToolCallOutputContentItem::InputText { - text: "inline image note".to_string(), - }, - DynamicToolCallOutputContentItem::InputImage { - image_url: image_url.to_string(), - }, - ], - success: true, - }, - ) - .await; - return Ok::<(), anyhow::Error>(()); - } - } - }; - - let (result, response_watcher_result) = tokio::join!( - manager.execute( - Arc::clone(&session), - Arc::clone(&turn), - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ), - response_watcher, - ); - response_watcher_result?; - let err = result.expect_err("mixed content should fail"); - assert!( - err.to_string() - .contains("does not accept mixed text and image content") - ); - assert!(session.get_pending_input().await.is_empty()); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_dynamic_tool_response_preserves_js_line_separator_text() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - for (tool_name, description, expected_text, literal) in [ - ( - "line_separator_tool", - "Returns text containing U+2028.", - "alpha\u{2028}omega".to_string(), - r#""alpha\u2028omega""#, - ), - ( - "paragraph_separator_tool", - "Returns text containing U+2029.", - "alpha\u{2029}omega".to_string(), - r#""alpha\u2029omega""#, - ), - ] { - let (session, turn, rx_event) = - make_session_and_context_with_dynamic_tools_and_rx(vec![DynamicToolSpec { - namespace: None, - name: tool_name.to_string(), - description: description.to_string(), - input_schema: serde_json::json!({ - "type": "object", - "properties": {}, - "additionalProperties": false - }), - defer_loading: false, - }]) - .await; - - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = format!( - r#" -const out = await codex.tool("{tool_name}", {{}}); -const text = typeof out === "string" ? out : out?.output; -console.log(text === {literal}); -console.log(text); -"# - ); - - let session_for_response = Arc::clone(&session); - let expected_text_for_response = expected_text.clone(); - let response_watcher = async move { - loop { - let event = tokio::time::timeout(Duration::from_secs(2), rx_event.recv()).await??; - if let EventMsg::DynamicToolCallRequest(request) = event.msg { - session_for_response - .notify_dynamic_tool_response( - &request.call_id, - DynamicToolResponse { - content_items: vec![DynamicToolCallOutputContentItem::InputText { - text: expected_text_for_response.clone(), - }], - success: true, - }, - ) - .await; - return Ok::<(), anyhow::Error>(()); - } - } - }; - - let (result, response_watcher_result) = tokio::join!( - manager.execute( - Arc::clone(&session), - Arc::clone(&turn), - tracker, - JsReplArgs { - code, - timeout_ms: Some(15_000), - }, - ), - response_watcher, - ); - response_watcher_result?; - - let result = result?; - assert_eq!(result.output, format!("true\n{expected_text}")); - } - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_can_call_hidden_dynamic_tools() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn, rx_event) = - make_session_and_context_with_dynamic_tools_and_rx(vec![DynamicToolSpec { - namespace: Some("codex_app".to_string()), - name: "hidden_dynamic_tool".to_string(), - description: "A hidden dynamic tool.".to_string(), - input_schema: serde_json::json!({ - "type": "object", - "properties": { - "city": { "type": "string" } - }, - "required": ["city"], - "additionalProperties": false - }), - defer_loading: true, - }]) - .await; - - *session.active_turn.lock().await = Some(crate::state::ActiveTurn::default()); - - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - let code = r#" -const out = await codex.tool("codex_app_hidden_dynamic_tool", { city: "Paris" }); -console.log(JSON.stringify(out)); -"#; - - let session_for_response = Arc::clone(&session); - let response_watcher = async move { - loop { - let event = tokio::time::timeout(Duration::from_secs(2), rx_event.recv()).await??; - if let EventMsg::DynamicToolCallRequest(request) = event.msg { - session_for_response - .notify_dynamic_tool_response( - &request.call_id, - DynamicToolResponse { - content_items: vec![DynamicToolCallOutputContentItem::InputText { - text: "hidden-ok".to_string(), - }], - success: true, - }, - ) - .await; - return Ok::<(), anyhow::Error>(()); - } - } - }; - - let (result, response_watcher_result) = tokio::join!( - manager.execute( - Arc::clone(&session), - Arc::clone(&turn), - tracker, - JsReplArgs { - code: code.to_string(), - timeout_ms: Some(15_000), - }, - ), - response_watcher, - ); - - let result = result?; - response_watcher_result?; - assert!(result.output.contains("hidden-ok")); - assert!(session.get_pending_input().await.is_empty()); - - Ok(()) -} - -#[tokio::test] -async fn js_repl_prefers_env_node_module_dirs_over_config() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let env_base = tempdir()?; - write_js_repl_test_package(env_base.path(), "repl_probe", "env")?; - - let config_base = tempdir()?; - let cwd_dir = tempdir()?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy.r#set.insert( - "CODEX_JS_REPL_NODE_MODULE_DIRS".to_string(), - env_base.path().to_string_lossy().to_string(), - ); - turn.cwd = cwd_dir.abs(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - vec![config_base.path().to_path_buf()], - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "const mod = await import(\"repl_probe\"); console.log(mod.value);" - .to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(result.output.contains("env")); - Ok(()) -} - -#[tokio::test] -async fn js_repl_resolves_from_first_config_dir() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let first_base = tempdir()?; - let second_base = tempdir()?; - write_js_repl_test_package(first_base.path(), "repl_probe", "first")?; - write_js_repl_test_package(second_base.path(), "repl_probe", "second")?; - - let cwd_dir = tempdir()?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.abs(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - vec![ - first_base.path().to_path_buf(), - second_base.path().to_path_buf(), - ], - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "const mod = await import(\"repl_probe\"); console.log(mod.value);" - .to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(result.output.contains("first")); - Ok(()) -} - -#[tokio::test] -async fn js_repl_falls_back_to_cwd_node_modules() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let config_base = tempdir()?; - let cwd_dir = tempdir()?; - write_js_repl_test_package(cwd_dir.path(), "repl_probe", "cwd")?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.abs(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - vec![config_base.path().to_path_buf()], - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "const mod = await import(\"repl_probe\"); console.log(mod.value);" - .to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(result.output.contains("cwd")); - Ok(()) -} - -#[tokio::test] -async fn js_repl_accepts_node_modules_dir_entries() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let base_dir = tempdir()?; - let cwd_dir = tempdir()?; - write_js_repl_test_package(base_dir.path(), "repl_probe", "normalized")?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.abs(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - vec![base_dir.path().join("node_modules")], - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "const mod = await import(\"repl_probe\"); console.log(mod.value);" - .to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(result.output.contains("normalized")); - Ok(()) -} - -#[tokio::test] -async fn js_repl_supports_relative_file_imports() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let cwd_dir = tempdir()?; - write_js_repl_test_module( - cwd_dir.path(), - "child.js", - "export const value = \"child\";\n", - )?; - write_js_repl_test_module( - cwd_dir.path(), - "parent.js", - "import { value as childValue } from \"./child.js\";\nexport const value = `${childValue}-parent`;\n", - )?; - write_js_repl_test_module( - cwd_dir.path(), - "local.mjs", - "export const value = \"mjs\";\n", - )?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.abs(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - Vec::new(), - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "const parent = await import(\"./parent.js\"); const other = await import(\"./local.mjs\"); console.log(parent.value); console.log(other.value);".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(result.output.contains("child-parent")); - assert!(result.output.contains("mjs")); - Ok(()) -} - -#[tokio::test] -async fn js_repl_supports_absolute_file_imports() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let module_dir = tempdir()?; - let cwd_dir = tempdir()?; - write_js_repl_test_module( - module_dir.path(), - "absolute.js", - "export const value = \"absolute\";\n", - )?; - let absolute_path_json = - serde_json::to_string(&module_dir.path().join("absolute.js").display().to_string())?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.abs(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - Vec::new(), - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: format!( - "const mod = await import({absolute_path_json}); console.log(mod.value);" - ), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(result.output.contains("absolute")); - Ok(()) -} - -#[tokio::test] -async fn js_repl_imported_local_files_can_access_repl_globals() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let cwd_dir = tempdir()?; - let expected_home_dir = serde_json::to_string("/tmp/codex-home")?; - write_js_repl_test_module( - cwd_dir.path(), - "globals.js", - &format!( - "const expectedHomeDir = {expected_home_dir};\nconsole.log(`tmp:${{codex.tmpDir === tmpDir}}`);\nconsole.log(`cwd:${{typeof codex.cwd}}:${{codex.cwd.length > 0}}`);\nconsole.log(`home:${{codex.homeDir === expectedHomeDir}}`);\nconsole.log(`tool:${{typeof codex.tool}}`);\nconsole.log(\"local-file-console-ok\");\n" - ), - )?; - - let (session, mut turn) = make_session_and_context().await; - session - .set_dependency_env(HashMap::from([( - "HOME".to_string(), - "/tmp/codex-home".to_string(), - )])) - .await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.abs(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - Vec::new(), - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "await import(\"./globals.js\");".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(result.output.contains("tmp:true")); - assert!(result.output.contains("cwd:string:true")); - assert!(result.output.contains("home:true")); - assert!(result.output.contains("tool:function")); - assert!(result.output.contains("local-file-console-ok")); - Ok(()) -} - -#[tokio::test] -async fn js_repl_reimports_local_files_after_edit() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let cwd_dir = tempdir()?; - let helper_path = cwd_dir.path().join("helper.js"); - fs::write(&helper_path, "export const value = \"v1\";\n")?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.abs(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - Vec::new(), - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let first = manager - .execute( - Arc::clone(&session), - Arc::clone(&turn), - Arc::clone(&tracker), - JsReplArgs { - code: "const { value: firstValue } = await import(\"./helper.js\");\nconsole.log(firstValue);".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(first.output.contains("v1")); - - fs::write(&helper_path, "export const value = \"v2\";\n")?; - - let second = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "console.log(firstValue);\nconst { value: secondValue } = await import(\"./helper.js\");\nconsole.log(secondValue);".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(second.output.contains("v1")); - assert!(second.output.contains("v2")); - Ok(()) -} - -#[tokio::test] -async fn js_repl_reimports_local_files_after_fixing_failure() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let cwd_dir = tempdir()?; - let helper_path = cwd_dir.path().join("broken.js"); - fs::write(&helper_path, "throw new Error(\"boom\");\n")?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.abs(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - Vec::new(), - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let err = manager - .execute( - Arc::clone(&session), - Arc::clone(&turn), - Arc::clone(&tracker), - JsReplArgs { - code: "await import(\"./broken.js\");".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await - .expect_err("expected broken module import to fail"); - assert!(err.to_string().contains("boom")); - - fs::write(&helper_path, "export const value = \"fixed\";\n")?; - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "console.log((await import(\"./broken.js\")).value);".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - assert!(result.output.contains("fixed")); - Ok(()) -} - -#[tokio::test] -async fn js_repl_local_files_expose_node_like_import_meta() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let cwd_dir = tempdir()?; - let pkg_dir = cwd_dir.path().join("node_modules").join("repl_meta_pkg"); - fs::create_dir_all(&pkg_dir)?; - fs::write( - pkg_dir.join("package.json"), - "{\n \"name\": \"repl_meta_pkg\",\n \"version\": \"1.0.0\",\n \"type\": \"module\",\n \"exports\": {\n \"import\": \"./index.js\"\n }\n}\n", - )?; - fs::write( - pkg_dir.join("index.js"), - "import { sep } from \"node:path\";\nexport const value = `pkg:${typeof sep}`;\n", - )?; - write_js_repl_test_module( - cwd_dir.path(), - "child.js", - "export const value = \"child-export\";\n", - )?; - write_js_repl_test_module( - cwd_dir.path(), - "meta.js", - "console.log(import.meta.url);\nconsole.log(import.meta.filename);\nconsole.log(import.meta.dirname);\nconsole.log(import.meta.main);\nconsole.log(import.meta.resolve(\"./child.js\"));\nconsole.log(import.meta.resolve(\"repl_meta_pkg\"));\nconsole.log(import.meta.resolve(\"node:fs\"));\nconsole.log((await import(import.meta.resolve(\"./child.js\"))).value);\nconsole.log((await import(import.meta.resolve(\"repl_meta_pkg\"))).value);\n", - )?; - let child_path = fs::canonicalize(cwd_dir.path().join("child.js"))?; - let child_url = url::Url::from_file_path(&child_path) - .expect("child path should convert to file URL") - .to_string(); - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.abs(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - Vec::new(), - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let result = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "await import(\"./meta.js\");".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await?; - let cwd_display = cwd_dir.path().display().to_string(); - let meta_path_display = cwd_dir.path().join("meta.js").display().to_string(); - assert!(result.output.contains("file://")); - assert!(result.output.contains(&meta_path_display)); - assert!(result.output.contains(&cwd_display)); - assert!(result.output.contains("false")); - assert!(result.output.contains(&child_url)); - assert!(result.output.contains("repl_meta_pkg")); - assert!(result.output.contains("node:fs")); - assert!(result.output.contains("child-export")); - assert!(result.output.contains("pkg:string")); - Ok(()) -} - -#[tokio::test] -async fn js_repl_rejects_top_level_static_imports_with_clear_error() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let (session, turn) = make_session_and_context().await; - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let err = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "import \"./local.js\";".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await - .expect_err("expected top-level static import to be rejected"); - assert!( - err.to_string() - .contains("Top-level static import \"./local.js\" is not supported in js_repl") - ); - Ok(()) -} - -#[tokio::test] -async fn js_repl_local_files_reject_static_bare_imports() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let cwd_dir = tempdir()?; - write_js_repl_test_package(cwd_dir.path(), "repl_counter", "pkg")?; - write_js_repl_test_module( - cwd_dir.path(), - "entry.js", - "import { value } from \"repl_counter\";\nconsole.log(value);\n", - )?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.abs(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - Vec::new(), - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let err = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "await import(\"./entry.js\");".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await - .expect_err("expected static bare import to be rejected"); - assert!( - err.to_string() - .contains("Static import \"repl_counter\" is not supported from js_repl local files") - ); - Ok(()) -} - -#[tokio::test] -async fn js_repl_rejects_unsupported_file_specifiers() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let cwd_dir = tempdir()?; - write_js_repl_test_module(cwd_dir.path(), "local.ts", "export const value = \"ts\";\n")?; - write_js_repl_test_module(cwd_dir.path(), "local", "export const value = \"noext\";\n")?; - fs::create_dir_all(cwd_dir.path().join("dir"))?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.abs(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - Vec::new(), - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let unsupported_extension = manager - .execute( - Arc::clone(&session), - Arc::clone(&turn), - Arc::clone(&tracker), - JsReplArgs { - code: "await import(\"./local.ts\");".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await - .expect_err("expected unsupported extension to be rejected"); - assert!( - unsupported_extension - .to_string() - .contains("Only .js and .mjs files are supported") - ); - - let extensionless = manager - .execute( - Arc::clone(&session), - Arc::clone(&turn), - Arc::clone(&tracker), - JsReplArgs { - code: "await import(\"./local\");".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await - .expect_err("expected extensionless import to be rejected"); - assert!( - extensionless - .to_string() - .contains("Only .js and .mjs files are supported") - ); - - let directory = manager - .execute( - Arc::clone(&session), - Arc::clone(&turn), - Arc::clone(&tracker), - JsReplArgs { - code: "await import(\"./dir\");".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await - .expect_err("expected directory import to be rejected"); - assert!( - directory - .to_string() - .contains("Directory imports are not supported") - ); - - let unsupported_url = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "await import(\"https://example.com/test.js\");".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await - .expect_err("expected unsupported url import to be rejected"); - assert!( - unsupported_url - .to_string() - .contains("Unsupported import specifier") - ); - Ok(()) -} - -#[tokio::test] -async fn js_repl_blocks_sensitive_builtin_imports_from_local_files() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let cwd_dir = tempdir()?; - write_js_repl_test_module( - cwd_dir.path(), - "blocked.js", - "import process from \"node:process\";\nconsole.log(process.pid);\n", - )?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.abs(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - Vec::new(), - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let err = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "await import(\"./blocked.js\");".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await - .expect_err("expected blocked builtin import to be rejected"); - assert!( - err.to_string() - .contains("Importing module \"node:process\" is not allowed in js_repl") - ); - Ok(()) -} - -#[tokio::test] -async fn js_repl_local_files_do_not_escape_node_module_search_roots() -> anyhow::Result<()> { - if !can_run_js_repl_runtime_tests().await { - return Ok(()); - } - - let parent_dir = tempdir()?; - write_js_repl_test_package(parent_dir.path(), "repl_probe", "parent")?; - let cwd_dir = parent_dir.path().join("workspace"); - fs::create_dir_all(&cwd_dir)?; - write_js_repl_test_module( - &cwd_dir, - "entry.js", - "const { value } = await import(\"repl_probe\");\nconsole.log(value);\n", - )?; - - let (session, mut turn) = make_session_and_context().await; - turn.shell_environment_policy - .r#set - .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.abs(); - turn.js_repl = Arc::new(JsReplHandle::with_node_path( - turn.config.js_repl_node_path.clone(), - Vec::new(), - )); - - let session = Arc::new(session); - let turn = Arc::new(turn); - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::default())); - let manager = turn.js_repl.manager().await?; - - let err = manager - .execute( - session, - turn, - tracker, - JsReplArgs { - code: "await import(\"./entry.js\");".to_string(), - timeout_ms: Some(10_000), - }, - ) - .await - .expect_err("expected parent node_modules lookup to be rejected"); - assert!(err.to_string().contains("repl_probe")); - Ok(()) -} diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index a6e31dc8b8..659a7d3e54 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -3,7 +3,6 @@ pub(crate) mod context; pub(crate) mod events; pub(crate) mod handlers; pub(crate) mod hook_names; -pub(crate) mod js_repl; pub(crate) mod network_approval; pub(crate) mod orchestrator; pub(crate) mod parallel; diff --git a/codex-rs/core/src/tools/router.rs b/codex-rs/core/src/tools/router.rs index ad635126cf..1f0dec6925 100644 --- a/codex-rs/core/src/tools/router.rs +++ b/codex-rs/core/src/tools/router.rs @@ -279,18 +279,6 @@ impl ToolRouter { payload, } = call; - let direct_js_repl_call = tool_name.namespace.is_none() - && matches!(tool_name.name.as_str(), "js_repl" | "js_repl_reset"); - if matches!(&source, ToolCallSource::Direct) - && turn.tools_config.js_repl_tools_only - && !direct_js_repl_call - { - return Err(FunctionCallError::RespondToModel( - "direct tool calls are disabled; use js_repl and codex.tool(...) instead" - .to_string(), - )); - } - let invocation = ToolInvocation { session, turn, diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index c83c65fc4e..e8c098c41e 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -1,187 +1,15 @@ use std::collections::HashSet; use std::sync::Arc; -use crate::function_tool::FunctionCallError; use crate::session::tests::make_session_and_context; use crate::tools::context::ToolPayload; -use crate::turn_diff_tracker::TurnDiffTracker; use codex_protocol::models::ResponseItem; use codex_tools::ToolName; -use tokio_util::sync::CancellationToken; use super::ToolCall; -use super::ToolCallSource; use super::ToolRouter; use super::ToolRouterParams; -#[tokio::test] -#[expect( - clippy::await_holding_invalid_type, - reason = "test builds a router from session-owned MCP manager state" -)] -async fn js_repl_tools_only_blocks_direct_tool_calls() -> anyhow::Result<()> { - let (session, mut turn) = make_session_and_context().await; - turn.tools_config.js_repl_tools_only = true; - - let session = Arc::new(session); - let turn = Arc::new(turn); - let mcp_tools = session - .services - .mcp_connection_manager - .read() - .await - .list_all_tools() - .await; - let deferred_mcp_tools = Some(mcp_tools.clone()); - let router = ToolRouter::from_config( - &turn.tools_config, - ToolRouterParams { - deferred_mcp_tools, - mcp_tools: Some(mcp_tools), - unavailable_called_tools: Vec::new(), - parallel_mcp_server_names: HashSet::new(), - discoverable_tools: None, - dynamic_tools: turn.dynamic_tools.as_slice(), - }, - ); - - let call = ToolCall { - tool_name: ToolName::plain("shell"), - call_id: "call-1".to_string(), - payload: ToolPayload::Function { - arguments: "{}".to_string(), - }, - }; - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let err = router - .dispatch_tool_call_with_code_mode_result( - session, - turn, - CancellationToken::new(), - tracker, - call, - ToolCallSource::Direct, - ) - .await - .err() - .expect("direct tool calls should be blocked"); - let FunctionCallError::RespondToModel(message) = err else { - panic!("expected RespondToModel, got {err:?}"); - }; - assert!(message.contains("direct tool calls are disabled")); - - Ok(()) -} - -#[tokio::test] -#[expect( - clippy::await_holding_invalid_type, - reason = "test builds a router from session-owned MCP manager state" -)] -async fn js_repl_tools_only_allows_js_repl_source_calls() -> anyhow::Result<()> { - let (session, mut turn) = make_session_and_context().await; - turn.tools_config.js_repl_tools_only = true; - - let session = Arc::new(session); - let turn = Arc::new(turn); - let mcp_tools = session - .services - .mcp_connection_manager - .read() - .await - .list_all_tools() - .await; - let deferred_mcp_tools = Some(mcp_tools.clone()); - let router = ToolRouter::from_config( - &turn.tools_config, - ToolRouterParams { - deferred_mcp_tools, - mcp_tools: Some(mcp_tools), - unavailable_called_tools: Vec::new(), - parallel_mcp_server_names: HashSet::new(), - discoverable_tools: None, - dynamic_tools: turn.dynamic_tools.as_slice(), - }, - ); - - let call = ToolCall { - tool_name: ToolName::plain("shell"), - call_id: "call-2".to_string(), - payload: ToolPayload::Function { - arguments: "{}".to_string(), - }, - }; - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let err = router - .dispatch_tool_call_with_code_mode_result( - session, - turn, - CancellationToken::new(), - tracker, - call, - ToolCallSource::JsRepl, - ) - .await - .err() - .expect("shell call with empty args should fail"); - let message = err.to_string(); - assert!( - !message.contains("direct tool calls are disabled"), - "js_repl source should bypass direct-call policy gate" - ); - - Ok(()) -} - -#[tokio::test] -async fn js_repl_tools_only_blocks_namespaced_js_repl_tool() -> anyhow::Result<()> { - let (session, mut turn) = make_session_and_context().await; - turn.tools_config.js_repl_tools_only = true; - - let session = Arc::new(session); - let turn = Arc::new(turn); - let router = ToolRouter::from_config( - &turn.tools_config, - ToolRouterParams { - deferred_mcp_tools: None, - mcp_tools: None, - unavailable_called_tools: Vec::new(), - parallel_mcp_server_names: HashSet::new(), - discoverable_tools: None, - dynamic_tools: turn.dynamic_tools.as_slice(), - }, - ); - - let call = ToolCall { - tool_name: ToolName::namespaced("mcp__server__", "js_repl"), - call_id: "call-namespaced-js-repl".to_string(), - payload: ToolPayload::Mcp { - server: "server".to_string(), - tool: "js_repl".to_string(), - raw_arguments: "{}".to_string(), - }, - }; - let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let err = router - .dispatch_tool_call_with_code_mode_result( - session, - turn, - CancellationToken::new(), - tracker, - call, - ToolCallSource::Direct, - ) - .await - .err() - .expect("namespaced js_repl calls should be blocked"); - let FunctionCallError::RespondToModel(message) = err else { - panic!("expected RespondToModel, got {err:?}"); - }; - assert!(message.contains("direct tool calls are disabled")); - - Ok(()) -} - #[tokio::test] #[expect( clippy::await_holding_invalid_type, diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 41dc422e3d..e556cab30f 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -80,8 +80,6 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; use crate::tools::handlers::DynamicToolHandler; - use crate::tools::handlers::JsReplHandler; - use crate::tools::handlers::JsReplResetHandler; use crate::tools::handlers::ListDirHandler; use crate::tools::handlers::McpHandler; use crate::tools::handlers::McpResourceHandler; @@ -167,8 +165,6 @@ pub(crate) fn build_specs_with_discoverable_tools( let tool_suggest_handler = Arc::new(ToolSuggestHandler); let code_mode_handler = Arc::new(CodeModeExecuteHandler); let code_mode_wait_handler = Arc::new(CodeModeWaitHandler); - let js_repl_handler = Arc::new(JsReplHandler); - let js_repl_reset_handler = Arc::new(JsReplResetHandler); let unavailable_tool_handler = Arc::new(UnavailableToolHandler); let mut existing_spec_names = plan .specs @@ -212,12 +208,6 @@ pub(crate) fn build_specs_with_discoverable_tools( ToolHandlerKind::FollowupTaskV2 => { builder.register_handler(handler.name, Arc::new(FollowupTaskHandlerV2)); } - ToolHandlerKind::JsRepl => { - builder.register_handler(handler.name, js_repl_handler.clone()); - } - ToolHandlerKind::JsReplReset => { - builder.register_handler(handler.name, js_repl_reset_handler.clone()); - } ToolHandlerKind::ListAgentsV2 => { builder.register_handler(handler.name, Arc::new(ListAgentsHandlerV2)); } diff --git a/codex-rs/core/src/tools/tool_dispatch_trace.rs b/codex-rs/core/src/tools/tool_dispatch_trace.rs index b95dc1b69f..344b686348 100644 --- a/codex-rs/core/src/tools/tool_dispatch_trace.rs +++ b/codex-rs/core/src/tools/tool_dispatch_trace.rs @@ -71,7 +71,6 @@ fn tool_dispatch_invocation(invocation: &ToolInvocation) -> Option return None, }; Some(ToolDispatchInvocation { @@ -98,7 +97,6 @@ fn tool_dispatch_result( ToolCallSource::CodeMode { .. } => Some(ToolDispatchResult::CodeModeResponse { value: result.code_mode_result(payload), }), - ToolCallSource::JsRepl => None, } } diff --git a/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs b/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs index b2a7cfe977..5f11816553 100644 --- a/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs +++ b/codex-rs/core/src/tools/tool_dispatch_trace_tests.rs @@ -129,11 +129,6 @@ async fn dispatch_lifecycle_trace_records_direct_and_code_mode_requesters() -> a Ok(()) } -#[tokio::test] -async fn dispatch_lifecycle_trace_skips_noncanonical_boundaries() -> anyhow::Result<()> { - assert_dispatch_trace_skips(ToolCallSource::JsRepl).await -} - #[tokio::test] async fn dispatch_lifecycle_trace_records_unsupported_tool_failures() -> anyhow::Result<()> { let temp = TempDir::new()?; @@ -234,35 +229,6 @@ async fn missing_code_mode_wait_traces_only_the_wait_tool_call() -> anyhow::Resu Ok(()) } -async fn assert_dispatch_trace_skips(source: ToolCallSource) -> anyhow::Result<()> { - let temp = TempDir::new()?; - let (mut session, turn) = make_session_and_context().await; - attach_test_trace(&mut session, &turn, temp.path())?; - - let registry = ToolRegistry::with_handler_for_test( - codex_tools::ToolName::plain("test_tool"), - Arc::new(TestHandler), - ); - let session = Arc::new(session); - let turn = Arc::new(turn); - - registry - .dispatch_any(test_invocation( - session, - turn, - "skipped-call", - "test_tool", - source, - "{}", - )) - .await?; - - let replayed = codex_rollout_trace::replay_bundle(single_bundle_dir(temp.path())?)?; - assert_eq!(replayed.tool_calls, Default::default()); - - Ok(()) -} - fn test_invocation( session: Arc, turn: Arc, diff --git a/codex-rs/core/tests/suite/js_repl.rs b/codex-rs/core/tests/suite/js_repl.rs deleted file mode 100644 index 450aa08f0b..0000000000 --- a/codex-rs/core/tests/suite/js_repl.rs +++ /dev/null @@ -1,795 +0,0 @@ -#![allow(clippy::expect_used, clippy::unwrap_used)] - -use anyhow::Result; -use codex_config::types::McpServerConfig; -use codex_config::types::McpServerTransportConfig; -use codex_features::Feature; -use codex_protocol::protocol::EventMsg; -use core_test_support::responses; -use core_test_support::responses::ResponseMock; -use core_test_support::responses::ResponsesRequest; -use core_test_support::responses::ev_assistant_message; -use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_custom_tool_call; -use core_test_support::responses::ev_response_created; -use core_test_support::responses::sse; -use core_test_support::skip_if_no_network; -use core_test_support::stdio_server_bin; -use core_test_support::test_codex::test_codex; -use core_test_support::wait_for_event_match; -use std::collections::HashMap; -use std::fs; -#[cfg(unix)] -use std::os::unix::fs::PermissionsExt; -use std::path::Path; -use std::time::Duration; -use tempfile::tempdir; -use wiremock::MockServer; - -fn custom_tool_output_text_and_success( - req: &ResponsesRequest, - call_id: &str, -) -> (String, Option) { - let (output, success) = req - .custom_tool_call_output_content_and_success(call_id) - .expect("custom tool output should be present"); - (output.unwrap_or_default(), success) -} - -fn assert_js_repl_ok(req: &ResponsesRequest, call_id: &str, expected_output: &str) { - let (output, success) = custom_tool_output_text_and_success(req, call_id); - assert_ne!( - success, - Some(false), - "js_repl call failed unexpectedly: {output}" - ); - assert!(output.contains(expected_output), "output was: {output}"); -} - -fn assert_js_repl_err(req: &ResponsesRequest, call_id: &str, expected_output: &str) { - let (output, success) = custom_tool_output_text_and_success(req, call_id); - assert_ne!(success, Some(true), "js_repl call should fail: {output}"); - assert!(output.contains(expected_output), "output was: {output}"); -} - -fn tool_names(body: &serde_json::Value) -> Vec { - body["tools"] - .as_array() - .expect("tools array should be present") - .iter() - .map(|tool| { - tool.get("name") - .and_then(|value| value.as_str()) - .or_else(|| tool.get("type").and_then(|value| value.as_str())) - .expect("tool should have a name or type") - .to_string() - }) - .collect() -} - -fn write_too_old_node_script(dir: &Path) -> Result { - #[cfg(windows)] - { - let path = dir.join("old-node.cmd"); - fs::write(&path, "@echo off\r\necho v0.0.1\r\n")?; - Ok(path) - } - - #[cfg(unix)] - { - let path = dir.join("old-node.sh"); - fs::write(&path, "#!/bin/sh\necho v0.0.1\n")?; - let mut permissions = fs::metadata(&path)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&path, permissions)?; - Ok(path) - } - - #[cfg(not(any(unix, windows)))] - { - anyhow::bail!("unsupported platform for js_repl test fixture"); - } -} - -async fn run_js_repl_turn( - server: &MockServer, - prompt: &str, - calls: &[(&str, &str)], -) -> Result { - let mut mocks = run_js_repl_sequence(server, prompt, calls).await?; - Ok(mocks - .pop() - .expect("js_repl test should return a request mock")) -} - -async fn run_js_repl_sequence( - server: &MockServer, - prompt: &str, - calls: &[(&str, &str)], -) -> Result> { - anyhow::ensure!( - !calls.is_empty(), - "js_repl test must include at least one call" - ); - - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::JsRepl) - .expect("test config should allow feature update"); - }); - let test = builder.build(server).await?; - - responses::mount_sse_once( - server, - sse(vec![ - ev_response_created("resp-1"), - ev_custom_tool_call(calls[0].0, "js_repl", calls[0].1), - ev_completed("resp-1"), - ]), - ) - .await; - - let mut mocks = Vec::with_capacity(calls.len()); - for (response_index, (call_id, js_input)) in calls.iter().enumerate().skip(1) { - let response_id = format!("resp-{}", response_index + 1); - let mock = responses::mount_sse_once( - server, - sse(vec![ - ev_response_created(&response_id), - ev_custom_tool_call(call_id, "js_repl", js_input), - ev_completed(&response_id), - ]), - ) - .await; - mocks.push(mock); - } - - let final_response_id = format!("resp-{}", calls.len() + 1); - let final_mock = responses::mount_sse_once( - server, - sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed(&final_response_id), - ]), - ) - .await; - mocks.push(final_mock); - - test.submit_turn(prompt).await?; - Ok(mocks) -} - -async fn assert_failed_cell_followup( - server: &MockServer, - prompt: &str, - failing_cell: &str, - followup_cell: &str, - expected_followup_output: &str, -) -> Result<()> { - let mocks = run_js_repl_sequence( - server, - prompt, - &[("call-1", failing_cell), ("call-2", followup_cell)], - ) - .await?; - - assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom"); - assert_js_repl_ok( - &mocks[1].single_request(), - "call-2", - expected_followup_output, - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_is_not_advertised_when_startup_node_is_incompatible() -> Result<()> { - skip_if_no_network!(Ok(())); - if std::env::var_os("CODEX_JS_REPL_NODE_PATH").is_some() { - return Ok(()); - } - - let server = responses::start_mock_server().await; - let temp = tempdir()?; - let old_node = write_too_old_node_script(temp.path())?; - - let mut builder = test_codex().with_config(move |config| { - config - .features - .enable(Feature::JsRepl) - .expect("test config should allow feature update"); - config.js_repl_node_path = Some(old_node); - }); - let test = builder.build(&server).await?; - let warning = wait_for_event_match(&test.codex, |event| match event { - EventMsg::Warning(ev) if ev.message.contains("Disabled `js_repl` for this session") => { - Some(ev.message.clone()) - } - _ => None, - }) - .await; - assert!( - warning.contains("Node runtime"), - "warning should explain the Node compatibility issue: {warning}" - ); - - let request_mock = responses::mount_sse_once( - &server, - sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-1"), - ]), - ) - .await; - - test.submit_turn("hello").await?; - - let body = request_mock.single_request().body_json(); - let tools = tool_names(&body); - assert!( - !tools.iter().any(|tool| tool == "js_repl"), - "js_repl should be omitted when startup validation fails: {tools:?}" - ); - assert!( - !tools.iter().any(|tool| tool == "js_repl_reset"), - "js_repl_reset should be omitted when startup validation fails: {tools:?}" - ); - let instructions = body["instructions"].as_str().unwrap_or_default(); - assert!( - !instructions.contains("## JavaScript REPL (Node)"), - "startup instructions should not mention js_repl when it is disabled: {instructions}" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_persists_top_level_destructured_bindings_and_supports_tla() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = responses::start_mock_server().await; - let mocks = run_js_repl_sequence( - &server, - "run js_repl twice", - &[ - ( - "call-1", - "const { context: liveContext, session } = await Promise.resolve({ context: 41, session: 1 }); console.log(liveContext + session);", - ), - ("call-2", "console.log(liveContext + session);"), - ], - ) - .await?; - - assert_js_repl_ok(&mocks[0].single_request(), "call-1", "42"); - assert_js_repl_ok(&mocks[1].single_request(), "call-2", "42"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_failed_cells_commit_initialized_bindings_only() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = responses::start_mock_server().await; - let mocks = run_js_repl_sequence( - &server, - "run js_repl across a failed cell", - &[ - ("call-1", "const base = 40; console.log(base);"), - ( - "call-2", - "const { session } = await Promise.resolve({ session: 2 }); throw new Error(\"boom\"); const late = 99;", - ), - ("call-3", "console.log(base + session, typeof late);"), - ], - ) - .await?; - - assert_js_repl_ok(&mocks[0].single_request(), "call-1", "40"); - assert_js_repl_err(&mocks[1].single_request(), "call-2", "boom"); - assert_js_repl_ok(&mocks[2].single_request(), "call-3", "42 undefined"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_failed_cells_preserve_initialized_lexical_destructuring_bindings() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = responses::start_mock_server().await; - let mocks = run_js_repl_sequence( - &server, - "run js_repl through partial destructuring failure", - &[ - ( - "call-1", - "const { a, b } = { a: 1, get b() { throw new Error(\"boom\"); } };", - ), - ( - "call-2", - "let aValue; try { aValue = a; } catch (error) { aValue = error.name; } let bValue; try { bValue = b; } catch (error) { bValue = error.name; } console.log(aValue, bValue);", - ), - ], - ) - .await?; - - assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom"); - assert_js_repl_ok(&mocks[1].single_request(), "call-2", "1 ReferenceError"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_link_failures_keep_prior_module_state() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = responses::start_mock_server().await; - let mocks = run_js_repl_sequence( - &server, - "run js_repl across a link failure", - &[ - ("call-1", "const answer = 41; console.log(answer);"), - ("call-2", "import value from \"./foo\";"), - ("call-3", "console.log(answer + 1);"), - ], - ) - .await?; - - assert_js_repl_ok(&mocks[0].single_request(), "call-1", "41"); - assert_js_repl_err( - &mocks[1].single_request(), - "call-2", - "Top-level static import \"./foo\" is not supported in js_repl", - ); - assert_js_repl_ok(&mocks[2].single_request(), "call-3", "42"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_failed_cells_do_not_commit_unreached_hoisted_bindings() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = responses::start_mock_server().await; - let mocks = run_js_repl_sequence( - &server, - "run js_repl through hoisted binding failure", - &[ - ( - "call-1", - "var early = 1; throw new Error(\"boom\"); var late = 2; function fn() { return 1; }", - ), - ( - "call-2", - "const late = 40; const fn = 1; console.log(early + late + fn);", - ), - ], - ) - .await?; - - assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom"); - assert_js_repl_ok(&mocks[1].single_request(), "call-2", "42"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_failed_cells_do_not_preserve_hoisted_function_reads_before_declaration() --> Result<()> { - skip_if_no_network!(Ok(())); - - let server = responses::start_mock_server().await; - let mocks = run_js_repl_sequence( - &server, - "run js_repl through unsupported hoisted function reads", - &[ - ( - "call-1", - "foo(); throw new Error(\"boom\"); function foo() {}", - ), - ( - "call-2", - "let value; try { foo; value = \"present\"; } catch (error) { value = error.name; } console.log(value);", - ), - ], - ) - .await?; - - assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom"); - assert_js_repl_ok(&mocks[1].single_request(), "call-2", "ReferenceError"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_failed_cells_preserve_functions_when_declaration_sites_are_reached() -> Result<()> -{ - skip_if_no_network!(Ok(())); - - let server = responses::start_mock_server().await; - let mocks = run_js_repl_sequence( - &server, - "run js_repl through supported function declaration persistence", - &[ - ("call-1", "function foo() {} throw new Error(\"boom\");"), - ("call-2", "console.log(typeof foo);"), - ], - ) - .await?; - - assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom"); - assert_js_repl_ok(&mocks[1].single_request(), "call-2", "function"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_failed_cells_preserve_prior_binding_writes_without_new_bindings() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = responses::start_mock_server().await; - let mocks = run_js_repl_sequence( - &server, - "run js_repl through failed prior-binding writes", - &[ - ("call-1", "let x = 1; console.log(x);"), - ("call-2", "x = 2; throw new Error(\"boom\");"), - ("call-3", "console.log(x);"), - ], - ) - .await?; - - assert_js_repl_ok(&mocks[0].single_request(), "call-1", "1"); - assert_js_repl_err(&mocks[1].single_request(), "call-2", "boom"); - assert_js_repl_ok(&mocks[2].single_request(), "call-3", "2"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_failed_cells_var_persistence_boundaries() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = responses::start_mock_server().await; - let cases = [ - ( - "run js_repl through supported pre-declaration var writes", - "x = 5; y = 1; y += 2; z = 1; z++; throw new Error(\"boom\"); var x, y, z;", - "console.log(x, y, z);", - "5 3 2", - ), - ( - "run js_repl through short-circuited logical var assignments", - "x &&= 1; y ||= 2; z ??= 3; throw new Error(\"boom\"); var x, y, z;", - "let xValue; try { xValue = x; } catch (error) { xValue = error.name; } console.log(xValue, y, z);", - "ReferenceError 2 3", - ), - ( - "run js_repl through unsupported shadowed nested var writes", - "{ let x = 1; x = 2; } throw new Error(\"boom\"); var x;", - "let value; try { value = x; } catch (error) { value = error.name; } console.log(value);", - "ReferenceError", - ), - ( - "run js_repl through unsupported nested assignment writes", - "x = (y = 1); throw new Error(\"boom\"); var x, y;", - "let yValue; try { yValue = y; } catch (error) { yValue = error.name; } console.log(x, yValue);", - "1 ReferenceError", - ), - ( - "run js_repl through unsupported var destructuring recovery", - "var { a, b } = { a: 1, get b() { throw new Error(\"boom\"); } };", - "let aValue; try { aValue = a; } catch (error) { aValue = error.name; } let bValue; try { bValue = b; } catch (error) { bValue = error.name; } console.log(aValue, bValue);", - "ReferenceError ReferenceError", - ), - ]; - - for (prompt, failing_cell, followup_cell, expected_followup_output) in cases { - assert_failed_cell_followup( - &server, - prompt, - failing_cell, - followup_cell, - expected_followup_output, - ) - .await?; - } - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_failed_cells_commit_non_empty_loop_vars_but_skip_empty_loops() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = responses::start_mock_server().await; - let mocks = run_js_repl_sequence( - &server, - "run js_repl through failed loop bindings", - &[ - ( - "call-1", - "for (var item of [2]) {} for (var emptyItem of []) {} throw new Error(\"boom\");", - ), - ( - "call-2", - "let itemValue; try { itemValue = item; } catch (error) { itemValue = error.name; } let emptyValue; try { emptyValue = emptyItem; } catch (error) { emptyValue = error.name; } console.log(itemValue, emptyValue);", - ), - ], - ) - .await?; - - assert_js_repl_err(&mocks[0].single_request(), "call-1", "boom"); - assert_js_repl_ok(&mocks[1].single_request(), "call-2", "2 ReferenceError"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_keeps_function_to_string_stable() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = responses::start_mock_server().await; - let mock = run_js_repl_turn( - &server, - "run js_repl through function toString", - &[( - "call-1", - "function foo() { return 1; } console.log(foo.toString());", - )], - ) - .await?; - - let req = mock.single_request(); - assert_js_repl_ok(&req, "call-1", "function foo() { return 1; }"); - let (output, _) = custom_tool_output_text_and_success(&req, "call-1"); - assert!(!output.contains("__codexInternalMarkCommittedBindings")); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_allows_globalthis_shadowing_with_instrumented_bindings() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = responses::start_mock_server().await; - let mock = run_js_repl_turn( - &server, - "run js_repl with shadowed globalThis", - &[( - "call-1", - "const globalThis = {}; const value = 1; console.log(typeof globalThis, value);", - )], - ) - .await?; - - let req = mock.single_request(); - assert_js_repl_ok(&req, "call-1", "object 1"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_can_invoke_builtin_tools() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = responses::start_mock_server().await; - let mock = run_js_repl_turn( - &server, - "use js_repl to call a tool", - &[( - "call-1", - "const toolOut = await codex.tool(\"list_mcp_resources\", {}); console.log(toolOut.type);", - )], - ) - .await?; - - let req = mock.single_request(); - let (output, success) = custom_tool_output_text_and_success(&req, "call-1"); - assert_ne!( - success, - Some(false), - "js_repl call failed unexpectedly: {output}" - ); - assert!(output.contains("function_call_output")); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_can_invoke_mcp_tools_by_display_name() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = responses::start_mock_server().await; - let rmcp_test_server_bin = stdio_server_bin()?; - let mut builder = test_codex().with_config(move |config| { - config - .features - .enable(Feature::JsRepl) - .expect("test config should allow feature update"); - - let mut servers = config.mcp_servers.get().clone(); - servers.insert( - "rmcp".to_string(), - McpServerConfig { - transport: McpServerTransportConfig::Stdio { - command: rmcp_test_server_bin, - args: Vec::new(), - env: None, - env_vars: Vec::new(), - cwd: None, - }, - experimental_environment: None, - enabled: true, - required: false, - supports_parallel_tool_calls: false, - disabled_reason: None, - startup_timeout_sec: Some(Duration::from_secs(10)), - tool_timeout_sec: None, - default_tools_approval_mode: None, - enabled_tools: None, - disabled_tools: None, - scopes: None, - oauth_resource: None, - tools: HashMap::new(), - }, - ); - config - .mcp_servers - .set(servers) - .expect("test mcp servers should accept any configuration"); - }); - let test = builder.build(&server).await?; - - responses::mount_sse_once( - &server, - sse(vec![ - ev_response_created("resp-1"), - ev_custom_tool_call( - "call-1", - "js_repl", - r#" -const result = await codex.tool("mcp__rmcp__echo", { message: "ping" }); -console.log(result.output); -"#, - ), - ev_completed("resp-1"), - ]), - ) - .await; - let final_mock = responses::mount_sse_once( - &server, - sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]), - ) - .await; - - test.submit_turn("use js_repl to call an MCP tool").await?; - - let req = final_mock.single_request(); - assert_js_repl_ok(&req, "call-1", "ECHOING: ping"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_tool_call_rejects_recursive_js_repl_invocation() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = responses::start_mock_server().await; - let mock = run_js_repl_turn( - &server, - "use js_repl recursively", - &[( - "call-1", - r#" -try { - await codex.tool("js_repl", "console.log('recursive')"); - console.log("unexpected-success"); -} catch (err) { - console.log(String(err)); -} -"#, - )], - ) - .await?; - - let req = mock.single_request(); - let (output, success) = custom_tool_output_text_and_success(&req, "call-1"); - assert_ne!( - success, - Some(false), - "js_repl call failed unexpectedly: {output}" - ); - assert!( - output.contains("js_repl cannot invoke itself"), - "expected recursion guard message, got output: {output}" - ); - assert!( - !output.contains("unexpected-success"), - "recursive js_repl call unexpectedly succeeded: {output}" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_does_not_expose_process_global() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = responses::start_mock_server().await; - let mock = run_js_repl_turn( - &server, - "check process visibility", - &[("call-1", "console.log(typeof process);")], - ) - .await?; - - let req = mock.single_request(); - let (output, success) = custom_tool_output_text_and_success(&req, "call-1"); - assert_ne!( - success, - Some(false), - "js_repl call failed unexpectedly: {output}" - ); - assert!(output.contains("undefined")); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_exposes_codex_path_helpers() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = responses::start_mock_server().await; - let mock = run_js_repl_turn( - &server, - "check codex path helpers", - &[( - "call-1", - "console.log(`cwd:${typeof codex.cwd}:${codex.cwd.length > 0}`); console.log(`home:${codex.homeDir === null || typeof codex.homeDir === \"string\"}`);", - )], - ) - .await?; - - let req = mock.single_request(); - let (output, success) = custom_tool_output_text_and_success(&req, "call-1"); - assert_ne!( - success, - Some(false), - "js_repl call failed unexpectedly: {output}" - ); - assert!(output.contains("cwd:string:true")); - assert!(output.contains("home:true")); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_blocks_sensitive_builtin_imports() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = responses::start_mock_server().await; - let mock = run_js_repl_turn( - &server, - "import a blocked module", - &[("call-1", "await import(\"node:process\");")], - ) - .await?; - - let req = mock.single_request(); - let (output, success) = custom_tool_output_text_and_success(&req, "call-1"); - assert_ne!( - success, - Some(true), - "blocked import unexpectedly succeeded: {output}" - ); - assert!(output.contains("Importing module \"node:process\" is not allowed in js_repl")); - - Ok(()) -} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 8f9f9e6840..b4adc6559d 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -51,7 +51,6 @@ mod hooks; mod hooks_mcp; mod image_rollout; mod items; -mod js_repl; mod json_result; mod live_cli; mod live_reload; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index db8ad21aa5..a2e15654e6 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -24,7 +24,6 @@ use codex_core::config::Config; use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::Environment; use codex_exec_server::HttpRequestParams; -use codex_features::Feature; use codex_login::CodexAuth; use codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY; use codex_models_manager::manager::RefreshStrategy; @@ -48,7 +47,6 @@ use codex_utils_cargo_bin::cargo_bin; use core_test_support::assert_regex_match; use core_test_support::remote_env_env_var; use core_test_support::responses; -use core_test_support::responses::ev_custom_tool_call; use core_test_support::responses::mount_models_once; use core_test_support::responses::mount_sse_once; use core_test_support::skip_if_no_network; @@ -1328,90 +1326,6 @@ async fn stdio_image_responses_preserve_original_detail_metadata() -> anyhow::Re Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[serial(mcp_test_value)] -async fn js_repl_emit_image_preserves_original_detail_for_mcp_images() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = responses::start_mock_server().await; - let call_id = "js-repl-rmcp-image"; - let rmcp_test_server_bin = stdio_server_bin()?; - - let fixture = test_codex() - .with_model("gpt-5.3-codex") - .with_config(move |config| { - config - .features - .enable(Feature::JsRepl) - .expect("test config should allow feature update"); - insert_mcp_server( - config, - "rmcp", - stdio_transport(rmcp_test_server_bin, /*env*/ None, Vec::new()), - TestMcpServerOptions::default(), - ); - }) - .build(&server) - .await?; - - wait_for_mcp_tool(&fixture, "mcp__rmcp__image_scenario").await?; - - mount_sse_once( - &server, - responses::sse(vec![ - responses::ev_response_created("resp-1"), - ev_custom_tool_call( - call_id, - "js_repl", - r#" -const out = await codex.tool("mcp__rmcp__image_scenario", { - scenario: "image_only_original_detail", -}); -const imageItem = out.output.find((item) => item.type === "input_image"); -await codex.emitImage(imageItem); -"#, - ), - responses::ev_completed("resp-1"), - ]), - ) - .await; - let final_mock = mount_sse_once( - &server, - responses::sse(vec![ - responses::ev_assistant_message("msg-1", "done"), - responses::ev_completed("resp-2"), - ]), - ) - .await; - - fixture - .submit_turn("use js_repl to emit the rmcp image scenario output") - .await?; - - let output = final_mock.single_request().custom_tool_call_output(call_id); - let output_items = output["output"] - .as_array() - .expect("js_repl output should be content items"); - let image_item = output_items - .iter() - .find(|item| item.get("type").and_then(Value::as_str) == Some("input_image")) - .expect("js_repl should emit an input_image item"); - assert_eq!( - image_item.get("detail").and_then(Value::as_str), - Some("original") - ); - assert!( - image_item - .get("image_url") - .and_then(Value::as_str) - .is_some_and(|image_url| image_url.starts_with("data:image/png;base64,")), - "js_repl should emit a png data URL" - ); - - server.verify().await; - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] #[serial(mcp_test_value)] async fn stdio_image_responses_are_sanitized_for_text_only_model() -> anyhow::Result<()> { diff --git a/codex-rs/core/tests/suite/tools.rs b/codex-rs/core/tests/suite/tools.rs index bc2bf2361d..2391e35fdd 100644 --- a/codex-rs/core/tests/suite/tools.rs +++ b/codex-rs/core/tests/suite/tools.rs @@ -96,10 +96,6 @@ async fn empty_turn_environments_omits_environment_backed_tools() -> Result<()> .features .enable(Feature::UnifiedExec) .expect("unified exec should enable for test"); - config - .features - .enable(Feature::JsRepl) - .expect("js repl should enable for test"); config.include_apply_patch_tool = true; }); let test = builder.build(&server).await?; @@ -112,14 +108,7 @@ async fn empty_turn_environments_omits_environment_backed_tools() -> Result<()> tools.contains(&"update_plan".to_string()), "non-environment tool should remain available; got {tools:?}" ); - for environment_tool in [ - "exec_command", - "write_stdin", - "js_repl", - "js_repl_reset", - "apply_patch", - "view_image", - ] { + for environment_tool in ["exec_command", "write_stdin", "apply_patch", "view_image"] { assert!( !tools.contains(&environment_tool.to_string()), "{environment_tool} should be omitted for explicit empty turn environments; got {tools:?}" diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 695acd0dd7..972e500eee 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -22,7 +22,6 @@ use codex_protocol::user_input::UserInput; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_custom_tool_call; use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_models_once; @@ -871,233 +870,6 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_only Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_emit_image_attaches_local_image() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::JsRepl) - .expect("test config should allow feature update"); - }); - let TestCodex { - codex, - cwd, - session_configured, - .. - } = builder.build(&server).await?; - - let call_id = "js-repl-view-image"; - let js_input = r#" -const fs = await import("node:fs/promises"); -const path = await import("node:path"); -const imagePath = path.join(codex.tmpDir, "js-repl-view-image.png"); -const png = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", - "base64" -); -await fs.writeFile(imagePath, png); -const out = await codex.tool("view_image", { path: imagePath }); -await codex.emitImage(out); -"#; - - let first_response = sse(vec![ - ev_response_created("resp-1"), - ev_custom_tool_call(call_id, "js_repl", js_input), - ev_completed("resp-1"), - ]); - responses::mount_sse_once(&server, first_response).await; - - let second_response = sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]); - let mock = responses::mount_sse_once(&server, second_response).await; - - let session_model = session_configured.model.clone(); - codex - .submit(Op::UserTurn { - environments: None, - items: vec![UserInput::Text { - text: "use js_repl to write an image and attach it".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy: SandboxPolicy::DangerFullAccess, - permission_profile: None, - model: session_model, - effort: None, - summary: None, - service_tier: None, - collaboration_mode: None, - personality: None, - }) - .await?; - - let mut tool_event = None; - wait_for_event_with_timeout( - &codex, - |event| match event { - EventMsg::ViewImageToolCall(_) => { - tool_event = Some(event.clone()); - false - } - EventMsg::TurnComplete(_) => true, - _ => false, - }, - VIEW_IMAGE_TURN_COMPLETE_TIMEOUT, - ) - .await; - let tool_event = match tool_event { - Some(EventMsg::ViewImageToolCall(event)) => event, - other => panic!("expected ViewImageToolCall event, got {other:?}"), - }; - assert!( - tool_event.path.ends_with("js-repl-view-image.png"), - "unexpected image path: {}", - tool_event.path.display() - ); - - let req = mock.single_request(); - let body = req.body_json(); - assert_eq!( - image_messages(&body).len(), - 0, - "js_repl view_image should not inject a pending input image message" - ); - - let custom_output = req.custom_tool_call_output(call_id); - let output_items = custom_output - .get("output") - .and_then(Value::as_array) - .expect("custom_tool_call_output should be a content item array"); - let image_url = output_items - .iter() - .find_map(|item| { - (item.get("type").and_then(Value::as_str) == Some("input_image")) - .then(|| item.get("image_url").and_then(Value::as_str)) - .flatten() - }) - .expect("image_url present in js_repl custom tool output"); - assert!( - image_url.starts_with("data:image/png;base64,"), - "expected png data URL, got {image_url}" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn js_repl_view_image_requires_explicit_emit() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - #[allow(clippy::expect_used)] - let mut builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::JsRepl) - .expect("test config should allow feature update"); - }); - let TestCodex { - codex, - cwd, - session_configured, - .. - } = builder.build(&server).await?; - - let call_id = "js-repl-view-image-no-emit"; - let js_input = r#" -const fs = await import("node:fs/promises"); -const path = await import("node:path"); -const imagePath = path.join(codex.tmpDir, "js-repl-view-image-no-emit.png"); -const png = Buffer.from( - "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", - "base64" -); -await fs.writeFile(imagePath, png); -const out = await codex.tool("view_image", { path: imagePath }); -console.log(out.type); -"#; - - let first_response = sse(vec![ - ev_response_created("resp-1"), - ev_custom_tool_call(call_id, "js_repl", js_input), - ev_completed("resp-1"), - ]); - responses::mount_sse_once(&server, first_response).await; - - let second_response = sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]); - let mock = responses::mount_sse_once(&server, second_response).await; - - let session_model = session_configured.model.clone(); - codex - .submit(Op::UserTurn { - environments: None, - items: vec![UserInput::Text { - text: "use js_repl to write an image but do not emit it".into(), - text_elements: Vec::new(), - }], - final_output_json_schema: None, - cwd: cwd.path().to_path_buf(), - approval_policy: AskForApproval::Never, - approvals_reviewer: None, - sandbox_policy: SandboxPolicy::DangerFullAccess, - permission_profile: None, - model: session_model, - effort: None, - service_tier: None, - summary: None, - collaboration_mode: None, - personality: None, - }) - .await?; - - let mut tool_event = None; - wait_for_event_with_timeout( - &codex, - |event| match event { - EventMsg::ViewImageToolCall(_) => { - tool_event = Some(event.clone()); - false - } - EventMsg::TurnComplete(_) => true, - _ => false, - }, - VIEW_IMAGE_TURN_COMPLETE_TIMEOUT, - ) - .await; - let tool_event = match tool_event { - Some(EventMsg::ViewImageToolCall(event)) => event, - other => panic!("expected ViewImageToolCall event, got {other:?}"), - }; - assert!( - tool_event.path.ends_with("js-repl-view-image-no-emit.png"), - "unexpected image path: {}", - tool_event.path.display() - ); - - let req = mock.single_request(); - let custom_output = req.custom_tool_call_output(call_id); - let output_items = custom_output.get("output").and_then(Value::as_array); - assert!( - output_items.is_none_or(|items| items - .iter() - .all(|item| item.get("type").and_then(Value::as_str) != Some("input_image"))), - "nested view_image should not auto-populate js_repl output" - ); - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); @@ -1572,4 +1344,3 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> Ok(()) } -use codex_features::Feature; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 24b12f4833..c96e06279b 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -399,8 +399,6 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result codex_self_exe: arg0_paths.codex_self_exe.clone(), codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(), - js_repl_node_path: None, - js_repl_node_module_dirs: None, zsh_path: None, base_instructions: None, developer_instructions: None, diff --git a/codex-rs/features/BUILD.bazel b/codex-rs/features/BUILD.bazel index bcb084f321..c67f572eea 100644 --- a/codex-rs/features/BUILD.bazel +++ b/codex-rs/features/BUILD.bazel @@ -10,7 +10,5 @@ codex_rust_crate( "Cargo.toml", ], allow_empty = True, - ) + [ - "//codex-rs:node-version.txt", - ], + ), ) diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 527cef36f8..453b421c0f 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -79,13 +79,13 @@ pub enum Feature { CodexHooks, // Experimental - /// Enable JavaScript REPL tools backed by a persistent Node kernel. + /// Removed compatibility flag for the deleted JavaScript REPL feature. JsRepl, - /// Enable a minimal JavaScript mode backed by Node's built-in vm runtime. + /// Enable JavaScript code mode backed by the in-process V8 runtime. CodeMode, /// Restrict model-visible tools to code mode entrypoints (`exec`, `wait`). CodeModeOnly, - /// Only expose js_repl tools directly to the model. + /// Removed compatibility flag for the deleted JavaScript REPL tool-only mode. JsReplToolsOnly, /// Use the single unified PTY-backed exec tool. UnifiedExec, @@ -388,6 +388,12 @@ impl Features { "tui_app_server" => { continue; } + "js_repl" => { + continue; + } + "js_repl_tools_only" => { + continue; + } "image_detail_original" => { continue; } @@ -457,10 +463,6 @@ impl Features { if self.enabled(Feature::CodeModeOnly) && !self.enabled(Feature::CodeMode) { self.enable(Feature::CodeMode); } - if self.enabled(Feature::JsReplToolsOnly) && !self.enabled(Feature::JsRepl) { - tracing::warn!("js_repl_tools_only requires js_repl; disabling js_repl_tools_only"); - self.disable(Feature::JsReplToolsOnly); - } } } @@ -644,11 +646,7 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::JsRepl, key: "js_repl", - stage: Stage::Experimental { - name: "JavaScript REPL", - menu_description: "Enable a persistent Node-backed JavaScript REPL for interactive website debugging and other inline JavaScript execution capabilities. Requires Node >= v22.22.0 installed.", - announcement: "NEW: JavaScript REPL is now available in /experimental. Enable it, then start a new chat or restart Codex to use it.", - }, + stage: Stage::Removed, default_enabled: false, }, FeatureSpec { @@ -666,7 +664,7 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::JsReplToolsOnly, key: "js_repl_tools_only", - stage: Stage::UnderDevelopment, + stage: Stage::Removed, default_enabled: false, }, FeatureSpec { diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 08c16590a1..69d51a740e 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -59,23 +59,6 @@ fn image_detail_original_is_removed_and_disabled_by_default() { assert_eq!(Feature::ImageDetailOriginal.default_enabled(), false); } -#[test] -fn js_repl_is_experimental_and_user_toggleable() { - let spec = Feature::JsRepl.info(); - let stage = spec.stage; - let expected_node_version = include_str!("../../node-version.txt").trim_end(); - - assert!(matches!(stage, Stage::Experimental { .. })); - assert_eq!(stage.experimental_menu_name(), Some("JavaScript REPL")); - assert_eq!( - stage.experimental_menu_description().map(str::to_owned), - Some(format!( - "Enable a persistent Node-backed JavaScript REPL for interactive website debugging and other inline JavaScript execution capabilities. Requires Node >= v{expected_node_version} installed." - )) - ); - assert_eq!(Feature::JsRepl.default_enabled(), false); -} - #[test] fn code_mode_only_requires_code_mode() { let mut features = Features::with_defaults(); @@ -222,6 +205,20 @@ fn image_detail_original_is_a_removed_feature_key() { ); } +#[test] +fn js_repl_features_are_removed_feature_keys() { + assert_eq!(Feature::JsRepl.stage(), Stage::Removed); + assert_eq!(Feature::JsRepl.default_enabled(), false); + assert_eq!(feature_for_key("js_repl"), Some(Feature::JsRepl)); + + assert_eq!(Feature::JsReplToolsOnly.stage(), Stage::Removed); + assert_eq!(Feature::JsReplToolsOnly.default_enabled(), false); + assert_eq!( + feature_for_key("js_repl_tools_only"), + Some(Feature::JsReplToolsOnly) + ); +} + #[test] fn tool_call_mcp_elicitation_is_stable_and_enabled_by_default() { assert_eq!(Feature::ToolCallMcpElicitation.stage(), Stage::Stable); @@ -353,6 +350,25 @@ fn from_sources_ignores_removed_image_detail_original_feature_key() { assert_eq!(features, Features::with_defaults()); } +#[test] +fn from_sources_ignores_removed_js_repl_feature_keys() { + let features_toml = FeaturesToml::from(BTreeMap::from([ + ("js_repl".to_string(), true), + ("js_repl_tools_only".to_string(), true), + ])); + + let features = Features::from_sources( + FeatureConfigSource { + features: Some(&features_toml), + ..Default::default() + }, + FeatureConfigSource::default(), + FeatureOverrides::default(), + ); + + assert_eq!(features, Features::with_defaults()); +} + #[test] fn multi_agent_v2_feature_config_deserializes_boolean_toggle() { let features: FeaturesToml = toml::from_str( diff --git a/codex-rs/node-version.txt b/codex-rs/node-version.txt deleted file mode 100644 index 85e502778f..0000000000 --- a/codex-rs/node-version.txt +++ /dev/null @@ -1 +0,0 @@ -22.22.0 diff --git a/codex-rs/tools/README.md b/codex-rs/tools/README.md index 474f35e6ac..ac6bba853b 100644 --- a/codex-rs/tools/README.md +++ b/codex-rs/tools/README.md @@ -22,7 +22,6 @@ schema and Responses API tool primitives that no longer need to live in - `ResponsesApiNamespace` - `ResponsesApiNamespaceTool` - code-mode `ToolSpec` adapters and `exec` / `wait` spec builders -- JS REPL spec builders - MCP resource, `list_dir`, and `test_sync_tool` spec builders - local host tool spec builders for shell/exec/request-permissions/view-image - collaboration and agent-job `ToolSpec` builders for spawn/send/wait/close, diff --git a/codex-rs/tools/src/js_repl_tool.rs b/codex-rs/tools/src/js_repl_tool.rs deleted file mode 100644 index 60089e0fb0..0000000000 --- a/codex-rs/tools/src/js_repl_tool.rs +++ /dev/null @@ -1,55 +0,0 @@ -use crate::FreeformTool; -use crate::FreeformToolFormat; -use crate::JsonSchema; -use crate::ResponsesApiTool; -use crate::ToolSpec; -use std::collections::BTreeMap; - -pub fn create_js_repl_tool() -> ToolSpec { - // Keep JS input freeform, but block the most common malformed payload shapes - // (JSON wrappers, quoted strings, and markdown fences) before they reach the - // runtime `reject_json_or_quoted_source` validation. The API's regex engine - // does not support look-around, so this uses a "first significant token" - // pattern rather than negative lookaheads. - const JS_REPL_FREEFORM_GRAMMAR: &str = r#" -start: pragma_source | plain_source - -pragma_source: PRAGMA_LINE NEWLINE js_source -plain_source: PLAIN_JS_SOURCE - -js_source: JS_SOURCE - -PRAGMA_LINE: /[ \t]*\/\/ codex-js-repl:[^\r\n]*/ -NEWLINE: /\r?\n/ -PLAIN_JS_SOURCE: /(?:\s*)(?:[^\s{\"`]|`[^`]|``[^`])[\s\S]*/ -JS_SOURCE: /(?:\s*)(?:[^\s{\"`]|`[^`]|``[^`])[\s\S]*/ -"#; - - ToolSpec::Freeform(FreeformTool { - name: "js_repl".to_string(), - description: "Runs JavaScript in a persistent Node kernel with top-level await. This is a freeform tool: send raw JavaScript source text, optionally with a first-line pragma like `// codex-js-repl: timeout_ms=15000`; do not send JSON/quotes/markdown fences." - .to_string(), - format: FreeformToolFormat { - r#type: "grammar".to_string(), - syntax: "lark".to_string(), - definition: JS_REPL_FREEFORM_GRAMMAR.to_string(), - }, - }) -} - -pub fn create_js_repl_reset_tool() -> ToolSpec { - ToolSpec::Function(ResponsesApiTool { - name: "js_repl_reset".to_string(), - description: - "Restarts the js_repl kernel for this run and clears persisted top-level bindings." - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::object(BTreeMap::new(), /*required*/ None, Some(false.into())), - output_schema: None, - }) -} - -#[cfg(test)] -#[path = "js_repl_tool_tests.rs"] -mod tests; diff --git a/codex-rs/tools/src/js_repl_tool_tests.rs b/codex-rs/tools/src/js_repl_tool_tests.rs deleted file mode 100644 index 7d6d63f179..0000000000 --- a/codex-rs/tools/src/js_repl_tool_tests.rs +++ /dev/null @@ -1,41 +0,0 @@ -use super::*; -use crate::JsonSchema; -use crate::ToolSpec; -use pretty_assertions::assert_eq; -use std::collections::BTreeMap; - -#[test] -fn js_repl_tool_uses_expected_freeform_grammar() { - let ToolSpec::Freeform(FreeformTool { format, .. }) = create_js_repl_tool() else { - panic!("js_repl should use a freeform tool spec"); - }; - - assert_eq!(format.syntax, "lark"); - assert!(format.definition.contains("PRAGMA_LINE")); - assert!(format.definition.contains("`[^`]")); - assert!(format.definition.contains("``[^`]")); - assert!(format.definition.contains("PLAIN_JS_SOURCE")); - assert!(format.definition.contains("codex-js-repl:")); - assert!(!format.definition.contains("(?!")); -} - -#[test] -fn js_repl_reset_tool_matches_expected_spec() { - assert_eq!( - create_js_repl_reset_tool(), - ToolSpec::Function(ResponsesApiTool { - name: "js_repl_reset".to_string(), - description: - "Restarts the js_repl kernel for this run and clears persisted top-level bindings." - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::object( - BTreeMap::new(), - /*required*/ None, - Some(false.into()) - ), - output_schema: None, - }) - ); -} diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 2dc7c165d5..fe140e4791 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -7,7 +7,6 @@ mod apply_patch_tool; mod code_mode; mod dynamic_tool; mod image_detail; -mod js_repl_tool; mod json_schema; mod local_tool; mod mcp_resource_tool; @@ -55,8 +54,6 @@ pub use dynamic_tool::parse_dynamic_tool; pub use image_detail::can_request_original_image_detail; pub use image_detail::normalize_output_image_detail; pub use image_detail::sanitize_original_image_detail; -pub use js_repl_tool::create_js_repl_reset_tool; -pub use js_repl_tool::create_js_repl_tool; pub use json_schema::AdditionalProperties; pub use json_schema::JsonSchema; pub use json_schema::JsonSchemaPrimitiveType; diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index 4c4689132d..8f27578c5d 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -99,8 +99,6 @@ pub struct ToolsConfig { pub request_permissions_tool_enabled: bool, pub code_mode_enabled: bool, pub code_mode_only_enabled: bool, - pub js_repl_enabled: bool, - pub js_repl_tools_only: bool, pub can_request_original_image_detail: bool, pub collab_tools: bool, pub multi_agent_v2: bool, @@ -141,9 +139,6 @@ impl ToolsConfig { let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform); let include_code_mode = features.enabled(Feature::CodeMode); let include_code_mode_only = include_code_mode && features.enabled(Feature::CodeModeOnly); - let include_js_repl = features.enabled(Feature::JsRepl); - let include_js_repl_tools_only = - include_js_repl && features.enabled(Feature::JsReplToolsOnly); let include_collab_tools = features.enabled(Feature::Collab); let include_multi_agent_v2 = features.enabled(Feature::MultiAgentV2); let include_agent_jobs = features.enabled(Feature::SpawnCsv); @@ -221,8 +216,6 @@ impl ToolsConfig { request_permissions_tool_enabled, code_mode_enabled: include_code_mode, code_mode_only_enabled: include_code_mode_only, - js_repl_enabled: include_js_repl, - js_repl_tools_only: include_js_repl_tools_only, can_request_original_image_detail: include_original_image_detail, collab_tools: include_collab_tools, multi_agent_v2: include_multi_agent_v2, diff --git a/codex-rs/tools/src/tool_registry_plan.rs b/codex-rs/tools/src/tool_registry_plan.rs index 8ae620532c..6b024658f3 100644 --- a/codex-rs/tools/src/tool_registry_plan.rs +++ b/codex-rs/tools/src/tool_registry_plan.rs @@ -29,8 +29,6 @@ use crate::create_code_mode_tool; use crate::create_exec_command_tool; use crate::create_followup_task_tool; use crate::create_image_generation_tool; -use crate::create_js_repl_reset_tool; -use crate::create_js_repl_tool; use crate::create_list_agents_tool; use crate::create_list_dir_tool; use crate::create_list_mcp_resource_templates_tool; @@ -218,21 +216,6 @@ pub fn build_tool_registry_plan( ); plan.register_handler("update_plan", ToolHandlerKind::Plan); - if config.has_environment && config.js_repl_enabled { - plan.push_spec( - create_js_repl_tool(), - /*supports_parallel_tool_calls*/ false, - config.code_mode_enabled, - ); - plan.push_spec( - create_js_repl_reset_tool(), - /*supports_parallel_tool_calls*/ false, - config.code_mode_enabled, - ); - plan.register_handler("js_repl", ToolHandlerKind::JsRepl); - plan.register_handler("js_repl_reset", ToolHandlerKind::JsReplReset); - } - plan.push_spec( create_request_user_input_tool(request_user_input_tool_description( config.default_mode_request_user_input, diff --git a/codex-rs/tools/src/tool_registry_plan_tests.rs b/codex-rs/tools/src/tool_registry_plan_tests.rs index 13dffb1c2d..c08666a2c2 100644 --- a/codex-rs/tools/src/tool_registry_plan_tests.rs +++ b/codex-rs/tools/src/tool_registry_plan_tests.rs @@ -437,7 +437,6 @@ fn disabled_environment_omits_environment_backed_tools() { let model_info = model_info(); let mut features = Features::with_defaults(); features.enable(Feature::UnifiedExec); - features.enable(Feature::JsRepl); let available_models = Vec::new(); let mut tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -462,8 +461,6 @@ fn disabled_environment_omits_environment_backed_tools() { assert_lacks_tool_name(&tools, "exec_command"); assert_lacks_tool_name(&tools, "write_stdin"); - assert_lacks_tool_name(&tools, "js_repl"); - assert_lacks_tool_name(&tools, "js_repl_reset"); assert_lacks_tool_name(&tools, "apply_patch"); assert_lacks_tool_name(&tools, "list_dir"); assert_lacks_tool_name(&tools, VIEW_IMAGE_TOOL_NAME); @@ -636,66 +633,6 @@ fn request_permissions_tool_is_independent_from_additional_permissions() { assert_lacks_tool_name(&tools, "request_permissions"); } -#[test] -fn js_repl_requires_feature_flag() { - let model_info = model_info(); - let features = Features::with_defaults(); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - assert!( - !tools.iter().any(|tool| tool.spec.name() == "js_repl"), - "js_repl should be disabled when the feature is off" - ); - assert!( - !tools.iter().any(|tool| tool.spec.name() == "js_repl_reset"), - "js_repl_reset should be disabled when the feature is off" - ); -} - -#[test] -fn js_repl_enabled_adds_tools() { - let model_info = model_info(); - let mut features = Features::with_defaults(); - features.enable(Feature::JsRepl); - - let available_models = Vec::new(); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_info: &model_info, - available_models: &available_models, - features: &features, - image_generation_tool_auth_allowed: true, - web_search_mode: Some(WebSearchMode::Cached), - session_source: SessionSource::Cli, - sandbox_policy: &SandboxPolicy::DangerFullAccess, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - }); - let (tools, _) = build_specs( - &tools_config, - /*mcp_tools*/ None, - /*deferred_mcp_tools*/ None, - &[], - ); - - assert_contains_tool_names(&tools, &["js_repl", "js_repl_reset"]); -} - #[test] fn image_generation_tools_require_feature_and_supported_model() { let supported_model_info = model_info(); diff --git a/codex-rs/tools/src/tool_registry_plan_types.rs b/codex-rs/tools/src/tool_registry_plan_types.rs index b9d66a0c2c..724f1bd15d 100644 --- a/codex-rs/tools/src/tool_registry_plan_types.rs +++ b/codex-rs/tools/src/tool_registry_plan_types.rs @@ -18,8 +18,6 @@ pub enum ToolHandlerKind { CodeModeWait, DynamicTool, FollowupTaskV2, - JsRepl, - JsReplReset, ListAgentsV2, ListDir, Mcp, 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 a800c9e7ed..3977d9d0ee 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -1751,30 +1751,6 @@ async fn experimental_features_toggle_saves_on_exit() { assert_eq!(updates, vec![(expected_feature, true)]); } -#[tokio::test] -async fn experimental_popup_shows_js_repl_node_requirement() { - let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - - let js_repl_description = FEATURES - .iter() - .find(|spec| spec.id == Feature::JsRepl) - .and_then(|spec| spec.stage.experimental_menu_description()) - .expect("expected js_repl experimental description"); - let node_requirement = js_repl_description - .split(". ") - .find(|sentence| sentence.starts_with("Requires Node >= v")) - .map(|sentence| sentence.trim_end_matches(" installed.")) - .expect("expected js_repl description to mention the Node requirement"); - - chat.open_experimental_popup(); - - let popup = render_bottom_popup(&chat, /*width*/ 120); - assert!( - popup.contains(node_requirement), - "expected js_repl feature description to mention the required Node version, got:\n{popup}" - ); -} - #[tokio::test] async fn experimental_popup_omits_stable_guardian_approval() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/docs/js_repl.md b/docs/js_repl.md deleted file mode 100644 index aee635b77a..0000000000 --- a/docs/js_repl.md +++ /dev/null @@ -1,155 +0,0 @@ -# JavaScript REPL (`js_repl`) - -`js_repl` runs JavaScript in a persistent Node-backed kernel with top-level `await`. - -## Feature gate - -`js_repl` is disabled by default and only appears when: - -```toml -[features] -js_repl = true -``` - -`js_repl_tools_only` can be enabled to force direct model tool calls through `js_repl`: - -```toml -[features] -js_repl = true -js_repl_tools_only = true -``` - -When enabled, direct model tool calls are restricted to `js_repl` and `js_repl_reset`; other tools remain available via `await codex.tool(...)` inside js_repl. - -## Node runtime - -`js_repl` requires a Node version that meets or exceeds `codex-rs/node-version.txt`. - -Runtime resolution order: - -1. `CODEX_JS_REPL_NODE_PATH` environment variable -2. `js_repl_node_path` in config/profile -3. `node` discovered on `PATH` - -You can configure an explicit runtime path: - -```toml -js_repl_node_path = "/absolute/path/to/node" -``` - -## Module resolution - -`js_repl` resolves **bare** specifiers (for example `await import("pkg")`) using an ordered -search path. Local file imports are also supported for relative paths, absolute paths, and -`file://` URLs that point to ESM `.js` / `.mjs` files. - -Module resolution proceeds in the following order: - -1. `CODEX_JS_REPL_NODE_MODULE_DIRS` (PATH-delimited list) -2. `js_repl_node_module_dirs` in config/profile (array of absolute paths) -3. Thread working directory (cwd, always included as the last fallback) - -For `CODEX_JS_REPL_NODE_MODULE_DIRS` and `js_repl_node_module_dirs`, module resolution is attempted in the order provided with earlier entries taking precedence. - -Bare package imports always use this REPL-wide search path, even when they originate from an -imported local file. They are not resolved relative to the imported file's location. - -## Usage - -- `js_repl` is a freeform tool: send raw JavaScript source text. -- Optional first-line pragma: - - `// codex-js-repl: timeout_ms=15000` -- Top-level bindings persist across calls. -- If a cell throws, prior bindings remain available, lexical bindings whose initialization completed before the throw stay available in later calls, and hoisted `var` / `function` bindings persist only when execution clearly reached their declaration or a supported write site. -- Supported hoisted-`var` failed-cell cases are direct top-level identifier writes and updates before the declaration (for example `x = 1`, `x += 1`, `x++`, `x &&= 1`) and non-empty top-level `for...in` / `for...of` loops. -- Intentionally unsupported failed-cell cases include hoisted function reads before the declaration, aliasing or direct-IIFE-based inference, writes in nested blocks or other nested statement structure, nested writes inside already-instrumented assignment RHS expressions, destructuring-assignment recovery for hoisted `var`, partial `var` destructuring recovery, pre-declaration `undefined` reads, and empty top-level `for...in` / `for...of` loop vars. -- Top-level static import declarations (for example `import x from "pkg"`) are currently unsupported; use dynamic imports with `await import("pkg")`. -- Imported local files must be ESM `.js` / `.mjs` files and run in the same REPL VM context as the calling cell. -- Static imports inside imported local files may only target other local `.js` / `.mjs` files via relative paths, absolute paths, or `file://` URLs. Bare package and builtin imports from local files must stay dynamic via `await import(...)`. -- `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:fs`; the returned value can be passed back to `await import(...)`. -- Local file modules reload between execs, so a later `await import("./file.js")` picks up edits and fixed failures. Top-level bindings you already created still persist until `js_repl_reset`. -- Use `js_repl_reset` to clear the kernel state. - -## Helper APIs inside the kernel - -`js_repl` exposes these globals: - -- `codex.cwd`: REPL working directory path. -- `codex.homeDir`: effective home directory path from the kernel environment. -- `codex.tmpDir`: per-session scratch directory path. -- `codex.tool(name, args?)`: executes a normal Codex tool call from inside `js_repl` (including shell tools like `shell` / `shell_command` when available). -- `codex.emitImage(imageLike)`: explicitly adds one image to the outer `js_repl` function output each time you call it. -- `codex.tool(...)` and `codex.emitImage(...)` keep stable helper identities across cells. Saved references and persisted objects can reuse them in later cells, but async callbacks that fire after a cell finishes still fail because no exec is active. -- Imported local files run in the same VM context, so they can also access `codex.*`, the captured `console`, and Node-like `import.meta` helpers. -- Each `codex.tool(...)` call emits a bounded summary at `info` level from the `codex_core::tools::js_repl` logger. At `trace` level, the same path also logs the exact raw response object or error string seen by JavaScript. -- Nested `codex.tool(...)` outputs stay inside JavaScript unless you emit them explicitly. -- `codex.emitImage(...)` accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object that contains exactly one image and no text. Call it multiple times if you want to emit multiple images. -- `codex.emitImage(...)` rejects mixed text-and-image content. -- Request full-resolution image processing with `detail: "original"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: "original"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents. -- Raw MCP image blocks can request the same behavior by returning `_meta: { "codex/imageDetail": "original" }` on the image content item. -- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: "jpeg", quality: 85 }), mimeType: "image/jpeg", detail: "original" })`. -- Example of sharing a local image tool result: `await codex.emitImage(codex.tool("view_image", { path: "/absolute/path", detail: "original" }))`. -- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits. - -Avoid writing directly to `process.stdout` / `process.stderr` / `process.stdin`; the kernel uses a JSON-line transport over stdio. - -## Debug logging - -Nested `codex.tool(...)` diagnostics are emitted through normal `tracing` output instead of rollout history. - -- `info` level logs a bounded summary. -- `trace` level also logs the exact serialized response object or error string seen by JavaScript. - -For `codex app-server`, these logs are written to the server process `stderr`. - -Examples: - -```sh -RUST_LOG=codex_core::tools::js_repl=info \ -LOG_FORMAT=json \ -codex app-server \ -2> /tmp/codex-app-server.log -``` - -```sh -RUST_LOG=codex_core::tools::js_repl=trace \ -LOG_FORMAT=json \ -codex app-server \ -2> /tmp/codex-app-server.log -``` - -In both cases, inspect `/tmp/codex-app-server.log` or whatever sink captures the process `stderr`. - -## Vendored parser asset (`meriyah.umd.min.js`) - -The kernel embeds a vendored Meriyah bundle at: - -- `codex-rs/core/src/tools/js_repl/meriyah.umd.min.js` - -Current source is `meriyah@7.0.0` from npm (`dist/meriyah.umd.min.js`). -Licensing is tracked in: - -- `third_party/meriyah/LICENSE` -- `NOTICE` - -### How this file was sourced - -From a clean temp directory: - -```sh -tmp="$(mktemp -d)" -cd "$tmp" -npm pack meriyah@7.0.0 -tar -xzf meriyah-7.0.0.tgz -cp package/dist/meriyah.umd.min.js /path/to/repo/codex-rs/core/src/tools/js_repl/meriyah.umd.min.js -cp package/LICENSE.md /path/to/repo/third_party/meriyah/LICENSE -``` - -### How to update to a newer version - -1. Replace `7.0.0` in the commands above with the target version. -2. Copy the new `dist/meriyah.umd.min.js` into `codex-rs/core/src/tools/js_repl/meriyah.umd.min.js`. -3. Copy the package license into `third_party/meriyah/LICENSE`. -4. Update the version string in the header comment at the top of `meriyah.umd.min.js`. -5. Update `NOTICE` if the upstream copyright notice changed. -6. Run the relevant `js_repl` tests. diff --git a/third_party/meriyah/LICENSE b/third_party/meriyah/LICENSE deleted file mode 100644 index 182e69f351..0000000000 --- a/third_party/meriyah/LICENSE +++ /dev/null @@ -1,15 +0,0 @@ -ISC License - -Copyright (c) 2019 and later, KFlash and others. - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY -AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. From 0ee737cea69f0907effceefa5da49e5ea5d0f39f Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 24 Apr 2026 20:51:38 -0700 Subject: [PATCH 13/67] Add goal persistence foundation (1 / 5) (#18073) Adds the persisted goal foundation for the rest of the stack. This PR is intentionally limited to feature flag and state-layer behavior; app-server APIs, model tools, runtime continuation, and TUI UX are layered in later PRs. ## Why Goal mode needs durable thread-level state before clients or model tools can safely build on it. The state layer needs to know whether a goal exists, what objective it tracks, whether it is active, paused, budget-limited, or complete, and how much time/token usage has already been accounted. ## What changed - Added the `goals` feature flag and generated config schema entry. - Added the `thread_goals` state table and Rust model for persisted thread goals. - Added state runtime APIs for creating, replacing, updating, deleting, and accounting goal usage. - Added `goal_id`-based stale update protection so an old goal update cannot overwrite a replacement. - Kept this PR scoped to persistence and state runtime behavior, with no app-server, model-facing, continuation, or TUI behavior yet. ## Verification - Added state runtime coverage for goal creation, replacement, stale update protection, status transitions, token-budget behavior, and usage accounting. --- codex-rs/core/config.schema.json | 6 + codex-rs/features/src/lib.rs | 8 + .../state/migrations/0029_thread_goals.sql | 11 + codex-rs/state/src/lib.rs | 5 + codex-rs/state/src/model/mod.rs | 4 + codex-rs/state/src/model/thread_goal.rs | 109 ++ codex-rs/state/src/runtime.rs | 5 + codex-rs/state/src/runtime/goals.rs | 1253 +++++++++++++++++ 8 files changed, 1401 insertions(+) create mode 100644 codex-rs/state/migrations/0029_thread_goals.sql create mode 100644 codex-rs/state/src/model/thread_goal.rs create mode 100644 codex-rs/state/src/runtime/goals.rs diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index a009fc2a55..dbc2316908 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -424,6 +424,9 @@ "general_analytics": { "type": "boolean" }, + "goals": { + "type": "boolean" + }, "guardian_approval": { "type": "boolean" }, @@ -2616,6 +2619,9 @@ "general_analytics": { "type": "boolean" }, + "goals": { + "type": "boolean" + }, "guardian_approval": { "type": "boolean" }, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 453b421c0f..6c4e97ab73 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -185,6 +185,8 @@ pub enum Feature { DefaultModeRequestUserInput, /// Enable automatic review for approval prompts. GuardianApproval, + /// Enable persisted thread goals and automatic goal continuation. + Goals, /// Enable collaboration modes (Plan, Default). /// Kept for config backward compatibility; behavior is always collaboration-modes-enabled. CollaborationModes, @@ -928,6 +930,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::Goals, + key: "goals", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::CollaborationModes, key: "collaboration_modes", diff --git a/codex-rs/state/migrations/0029_thread_goals.sql b/codex-rs/state/migrations/0029_thread_goals.sql new file mode 100644 index 0000000000..6c6e86a199 --- /dev/null +++ b/codex-rs/state/migrations/0029_thread_goals.sql @@ -0,0 +1,11 @@ +CREATE TABLE thread_goals ( + thread_id TEXT PRIMARY KEY NOT NULL REFERENCES threads(id) ON DELETE CASCADE, + goal_id TEXT NOT NULL, + objective TEXT NOT NULL, + status TEXT NOT NULL CHECK(status IN ('active', 'paused', 'budget_limited', 'complete')), + token_budget INTEGER, + tokens_used INTEGER NOT NULL DEFAULT 0, + time_used_seconds INTEGER NOT NULL DEFAULT 0, + created_at_ms INTEGER NOT NULL, + updated_at_ms INTEGER NOT NULL +); diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index 49529f3a33..c3dacae715 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -44,12 +44,17 @@ pub use model::Stage1JobClaimOutcome; pub use model::Stage1Output; pub use model::Stage1OutputRef; pub use model::Stage1StartupClaimParams; +pub use model::ThreadGoal; +pub use model::ThreadGoalStatus; pub use model::ThreadMetadata; pub use model::ThreadMetadataBuilder; pub use model::ThreadsPage; pub use runtime::DeviceKeyBindingRecord; pub use runtime::RemoteControlEnrollmentRecord; pub use runtime::ThreadFilterOptions; +pub use runtime::ThreadGoalAccountingMode; +pub use runtime::ThreadGoalAccountingOutcome; +pub use runtime::ThreadGoalUpdate; pub use runtime::logs_db_filename; pub use runtime::logs_db_path; pub use runtime::state_db_filename; diff --git a/codex-rs/state/src/model/mod.rs b/codex-rs/state/src/model/mod.rs index a5f9531aaa..213ae81fea 100644 --- a/codex-rs/state/src/model/mod.rs +++ b/codex-rs/state/src/model/mod.rs @@ -3,6 +3,7 @@ mod backfill_state; mod graph; mod log; mod memories; +mod thread_goal; mod thread_metadata; pub use agent_job::AgentJob; @@ -25,6 +26,8 @@ pub use memories::Stage1JobClaimOutcome; pub use memories::Stage1Output; pub use memories::Stage1OutputRef; pub use memories::Stage1StartupClaimParams; +pub use thread_goal::ThreadGoal; +pub use thread_goal::ThreadGoalStatus; pub use thread_metadata::Anchor; pub use thread_metadata::BackfillStats; pub use thread_metadata::ExtractionOutcome; @@ -38,6 +41,7 @@ pub(crate) use agent_job::AgentJobItemRow; pub(crate) use agent_job::AgentJobRow; pub(crate) use memories::Stage1OutputRow; pub(crate) use memories::stage1_output_ref_from_parts; +pub(crate) use thread_goal::ThreadGoalRow; pub(crate) use thread_metadata::ThreadRow; pub(crate) use thread_metadata::anchor_from_item; pub(crate) use thread_metadata::datetime_to_epoch_millis; diff --git a/codex-rs/state/src/model/thread_goal.rs b/codex-rs/state/src/model/thread_goal.rs new file mode 100644 index 0000000000..e943c145f8 --- /dev/null +++ b/codex-rs/state/src/model/thread_goal.rs @@ -0,0 +1,109 @@ +use anyhow::Result; +use anyhow::anyhow; +use chrono::DateTime; +use chrono::Utc; +use codex_protocol::ThreadId; +use sqlx::Row; +use sqlx::sqlite::SqliteRow; + +use super::epoch_millis_to_datetime; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThreadGoalStatus { + Active, + Paused, + BudgetLimited, + Complete, +} + +impl ThreadGoalStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::Paused => "paused", + Self::BudgetLimited => "budget_limited", + Self::Complete => "complete", + } + } + + pub fn is_active(self) -> bool { + self == Self::Active + } + + pub fn is_terminal(self) -> bool { + matches!(self, Self::BudgetLimited | Self::Complete) + } +} + +impl TryFrom<&str> for ThreadGoalStatus { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + match value { + "active" => Ok(Self::Active), + "paused" => Ok(Self::Paused), + "budget_limited" => Ok(Self::BudgetLimited), + "complete" => Ok(Self::Complete), + other => Err(anyhow!("unknown thread goal status `{other}`")), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ThreadGoal { + pub thread_id: ThreadId, + pub goal_id: String, + pub objective: String, + pub status: ThreadGoalStatus, + pub token_budget: Option, + pub tokens_used: i64, + pub time_used_seconds: i64, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +pub(crate) struct ThreadGoalRow { + pub thread_id: String, + pub goal_id: String, + pub objective: String, + pub status: String, + pub token_budget: Option, + pub tokens_used: i64, + pub time_used_seconds: i64, + pub created_at_ms: i64, + pub updated_at_ms: i64, +} + +impl ThreadGoalRow { + pub(crate) fn try_from_row(row: &SqliteRow) -> Result { + Ok(Self { + thread_id: row.try_get("thread_id")?, + goal_id: row.try_get("goal_id")?, + objective: row.try_get("objective")?, + status: row.try_get("status")?, + token_budget: row.try_get("token_budget")?, + tokens_used: row.try_get("tokens_used")?, + time_used_seconds: row.try_get("time_used_seconds")?, + created_at_ms: row.try_get("created_at_ms")?, + updated_at_ms: row.try_get("updated_at_ms")?, + }) + } +} + +impl TryFrom for ThreadGoal { + type Error = anyhow::Error; + + fn try_from(row: ThreadGoalRow) -> Result { + Ok(Self { + thread_id: ThreadId::try_from(row.thread_id)?, + goal_id: row.goal_id, + objective: row.objective, + status: ThreadGoalStatus::try_from(row.status.as_str())?, + token_budget: row.token_budget, + tokens_used: row.tokens_used, + time_used_seconds: row.time_used_seconds, + created_at: epoch_millis_to_datetime(row.created_at_ms)?, + updated_at: epoch_millis_to_datetime(row.updated_at_ms)?, + }) + } +} diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index ec3ce79e82..18f81348a2 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -20,6 +20,7 @@ use crate::apply_rollout_item; use crate::migrations::runtime_logs_migrator; use crate::migrations::runtime_state_migrator; use crate::model::AgentJobRow; +use crate::model::ThreadGoalRow; use crate::model::ThreadRow; use crate::model::anchor_from_item; use crate::model::datetime_to_epoch_millis; @@ -58,6 +59,7 @@ mod backfill; mod device_key; #[cfg(test)] mod device_key_tests; +mod goals; mod logs; mod memories; mod remote_control; @@ -66,6 +68,9 @@ mod test_support; mod threads; pub use device_key::DeviceKeyBindingRecord; +pub use goals::ThreadGoalAccountingMode; +pub use goals::ThreadGoalAccountingOutcome; +pub use goals::ThreadGoalUpdate; pub use remote_control::RemoteControlEnrollmentRecord; pub use threads::ThreadFilterOptions; diff --git a/codex-rs/state/src/runtime/goals.rs b/codex-rs/state/src/runtime/goals.rs new file mode 100644 index 0000000000..56613b7d92 --- /dev/null +++ b/codex-rs/state/src/runtime/goals.rs @@ -0,0 +1,1253 @@ +use super::*; +use uuid::Uuid; + +pub struct ThreadGoalUpdate { + pub status: Option, + pub token_budget: Option>, + pub expected_goal_id: Option, +} + +pub enum ThreadGoalAccountingOutcome { + Unchanged(Option), + Updated(crate::ThreadGoal), +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ThreadGoalAccountingMode { + ActiveStatusOnly, + ActiveOnly, + ActiveOrComplete, + ActiveOrStopped, +} + +impl StateRuntime { + pub async fn get_thread_goal( + &self, + thread_id: ThreadId, + ) -> anyhow::Result> { + let row = sqlx::query( + r#" +SELECT + thread_id, + goal_id, + objective, + status, + token_budget, + tokens_used, + time_used_seconds, + created_at_ms, + updated_at_ms +FROM thread_goals +WHERE thread_id = ? + "#, + ) + .bind(thread_id.to_string()) + .fetch_optional(self.pool.as_ref()) + .await?; + + row.map(|row| thread_goal_from_row(&row)).transpose() + } + + pub async fn replace_thread_goal( + &self, + thread_id: ThreadId, + objective: &str, + status: crate::ThreadGoalStatus, + token_budget: Option, + ) -> anyhow::Result { + let goal_id = Uuid::new_v4().to_string(); + let now_ms = datetime_to_epoch_millis(Utc::now()); + let status = status_after_budget_limit(status, /*tokens_used*/ 0, token_budget); + let row = sqlx::query( + r#" +INSERT INTO thread_goals ( + thread_id, + goal_id, + objective, + status, + token_budget, + tokens_used, + time_used_seconds, + created_at_ms, + updated_at_ms +) VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?) +ON CONFLICT(thread_id) DO UPDATE SET + goal_id = excluded.goal_id, + objective = excluded.objective, + status = excluded.status, + token_budget = excluded.token_budget, + tokens_used = 0, + time_used_seconds = 0, + created_at_ms = excluded.created_at_ms, + updated_at_ms = excluded.updated_at_ms +RETURNING + thread_id, + goal_id, + objective, + status, + token_budget, + tokens_used, + time_used_seconds, + created_at_ms, + updated_at_ms + "#, + ) + .bind(thread_id.to_string()) + .bind(goal_id) + .bind(objective) + .bind(status.as_str()) + .bind(token_budget) + .bind(now_ms) + .bind(now_ms) + .fetch_one(self.pool.as_ref()) + .await?; + + thread_goal_from_row(&row) + } + + pub async fn insert_thread_goal( + &self, + thread_id: ThreadId, + objective: &str, + status: crate::ThreadGoalStatus, + token_budget: Option, + ) -> anyhow::Result> { + let goal_id = Uuid::new_v4().to_string(); + let now_ms = datetime_to_epoch_millis(Utc::now()); + let status = status_after_budget_limit(status, /*tokens_used*/ 0, token_budget); + let row = sqlx::query( + r#" +INSERT INTO thread_goals ( + thread_id, + goal_id, + objective, + status, + token_budget, + tokens_used, + time_used_seconds, + created_at_ms, + updated_at_ms +) VALUES (?, ?, ?, ?, ?, 0, 0, ?, ?) +ON CONFLICT(thread_id) DO NOTHING +RETURNING + thread_id, + goal_id, + objective, + status, + token_budget, + tokens_used, + time_used_seconds, + created_at_ms, + updated_at_ms + "#, + ) + .bind(thread_id.to_string()) + .bind(goal_id) + .bind(objective) + .bind(status.as_str()) + .bind(token_budget) + .bind(now_ms) + .bind(now_ms) + .fetch_optional(self.pool.as_ref()) + .await?; + + row.map(|row| thread_goal_from_row(&row)).transpose() + } + + pub async fn update_thread_goal( + &self, + thread_id: ThreadId, + update: ThreadGoalUpdate, + ) -> anyhow::Result> { + let ThreadGoalUpdate { + status, + token_budget, + expected_goal_id, + } = update; + let expected_goal_id = expected_goal_id.as_deref(); + let now_ms = datetime_to_epoch_millis(Utc::now()); + let result = match (status, token_budget) { + (Some(status), Some(token_budget)) => { + sqlx::query( + r#" +UPDATE thread_goals +SET + status = CASE + WHEN status = ? AND ? = ? THEN status + WHEN ? = 'active' AND ? IS NOT NULL AND tokens_used >= ? THEN ? + ELSE ? + END, + token_budget = ?, + updated_at_ms = ? +WHERE thread_id = ? + AND (? IS NULL OR goal_id = ?) + "#, + ) + .bind(crate::ThreadGoalStatus::BudgetLimited.as_str()) + .bind(status.as_str()) + .bind(crate::ThreadGoalStatus::Paused.as_str()) + .bind(status.as_str()) + .bind(token_budget) + .bind(token_budget) + .bind(crate::ThreadGoalStatus::BudgetLimited.as_str()) + .bind(status.as_str()) + .bind(token_budget) + .bind(now_ms) + .bind(thread_id.to_string()) + .bind(expected_goal_id) + .bind(expected_goal_id) + .execute(self.pool.as_ref()) + .await? + } + (Some(status), None) => { + sqlx::query( + r#" +UPDATE thread_goals +SET + status = CASE + WHEN status = ? AND ? = ? THEN status + WHEN ? = 'active' AND token_budget IS NOT NULL AND tokens_used >= token_budget THEN ? + ELSE ? + END, + updated_at_ms = ? +WHERE thread_id = ? + AND (? IS NULL OR goal_id = ?) + "#, + ) + .bind(crate::ThreadGoalStatus::BudgetLimited.as_str()) + .bind(status.as_str()) + .bind(crate::ThreadGoalStatus::Paused.as_str()) + .bind(status.as_str()) + .bind(crate::ThreadGoalStatus::BudgetLimited.as_str()) + .bind(status.as_str()) + .bind(now_ms) + .bind(thread_id.to_string()) + .bind(expected_goal_id) + .bind(expected_goal_id) + .execute(self.pool.as_ref()) + .await? + } + (None, Some(token_budget)) => { + sqlx::query( + r#" +UPDATE thread_goals +SET + token_budget = ?, + status = CASE + WHEN status = 'active' AND ? IS NOT NULL AND tokens_used >= ? THEN ? + ELSE status + END, + updated_at_ms = ? +WHERE thread_id = ? + AND (? IS NULL OR goal_id = ?) + "#, + ) + .bind(token_budget) + .bind(token_budget) + .bind(token_budget) + .bind(crate::ThreadGoalStatus::BudgetLimited.as_str()) + .bind(now_ms) + .bind(thread_id.to_string()) + .bind(expected_goal_id) + .bind(expected_goal_id) + .execute(self.pool.as_ref()) + .await? + } + (None, None) => { + let goal = self.get_thread_goal(thread_id).await?; + return Ok(match (goal, expected_goal_id) { + (Some(goal), Some(expected_goal_id)) if goal.goal_id != expected_goal_id => { + None + } + (goal, _) => goal, + }); + } + }; + + if result.rows_affected() == 0 { + return Ok(None); + } + + self.get_thread_goal(thread_id).await + } + + pub async fn pause_active_thread_goal( + &self, + thread_id: ThreadId, + ) -> anyhow::Result> { + let now_ms = datetime_to_epoch_millis(Utc::now()); + let result = sqlx::query( + r#" +UPDATE thread_goals +SET + status = ?, + updated_at_ms = ? +WHERE thread_id = ? + AND status = 'active' + "#, + ) + .bind(crate::ThreadGoalStatus::Paused.as_str()) + .bind(now_ms) + .bind(thread_id.to_string()) + .execute(self.pool.as_ref()) + .await?; + + if result.rows_affected() == 0 { + return Ok(None); + } + + self.get_thread_goal(thread_id).await + } + + pub async fn delete_thread_goal(&self, thread_id: ThreadId) -> anyhow::Result { + let result = sqlx::query( + r#" +DELETE FROM thread_goals +WHERE thread_id = ? + "#, + ) + .bind(thread_id.to_string()) + .execute(self.pool.as_ref()) + .await?; + + Ok(result.rows_affected() > 0) + } + + pub async fn account_thread_goal_usage( + &self, + thread_id: ThreadId, + time_delta_seconds: i64, + token_delta: i64, + mode: ThreadGoalAccountingMode, + expected_goal_id: Option<&str>, + ) -> anyhow::Result { + let time_delta_seconds = time_delta_seconds.max(0); + let token_delta = token_delta.max(0); + if time_delta_seconds == 0 && token_delta == 0 { + return Ok(ThreadGoalAccountingOutcome::Unchanged( + self.get_thread_goal(thread_id).await?, + )); + } + + let now_ms = datetime_to_epoch_millis(Utc::now()); + let status_filter = match mode { + ThreadGoalAccountingMode::ActiveStatusOnly => "status = 'active'", + ThreadGoalAccountingMode::ActiveOnly => "status IN ('active', 'budget_limited')", + ThreadGoalAccountingMode::ActiveOrComplete => { + "status IN ('active', 'budget_limited', 'complete')" + } + ThreadGoalAccountingMode::ActiveOrStopped => { + "status IN ('active', 'paused', 'budget_limited')" + } + }; + let budget_limit_status_filter = match mode { + ThreadGoalAccountingMode::ActiveStatusOnly + | ThreadGoalAccountingMode::ActiveOnly + | ThreadGoalAccountingMode::ActiveOrComplete => "status = 'active'", + ThreadGoalAccountingMode::ActiveOrStopped => { + "status IN ('active', 'paused', 'budget_limited')" + } + }; + let goal_id_filter = if expected_goal_id.is_some() { + "goal_id = ?" + } else { + "1 = 1" + }; + let query = format!( + r#" +UPDATE thread_goals +SET + time_used_seconds = time_used_seconds + ?, + tokens_used = tokens_used + ?, + status = CASE + WHEN {budget_limit_status_filter} AND token_budget IS NOT NULL AND tokens_used + ? >= token_budget + THEN ? + ELSE status + END, + updated_at_ms = ? +WHERE thread_id = ? + AND {status_filter} + AND {goal_id_filter} +RETURNING + thread_id, + goal_id, + objective, + status, + token_budget, + tokens_used, + time_used_seconds, + created_at_ms, + updated_at_ms + "#, + ); + + let mut query = sqlx::query(&query) + .bind(time_delta_seconds) + .bind(token_delta) + .bind(token_delta) + .bind(crate::ThreadGoalStatus::BudgetLimited.as_str()) + .bind(now_ms) + .bind(thread_id.to_string()); + if let Some(expected_goal_id) = expected_goal_id { + query = query.bind(expected_goal_id); + } + + let row = query.fetch_optional(self.pool.as_ref()).await?; + + let Some(row) = row else { + return Ok(ThreadGoalAccountingOutcome::Unchanged( + self.get_thread_goal(thread_id).await?, + )); + }; + + let updated = thread_goal_from_row(&row)?; + Ok(ThreadGoalAccountingOutcome::Updated(updated)) + } +} + +fn thread_goal_from_row(row: &sqlx::sqlite::SqliteRow) -> anyhow::Result { + ThreadGoalRow::try_from_row(row).and_then(crate::ThreadGoal::try_from) +} + +fn status_after_budget_limit( + status: crate::ThreadGoalStatus, + tokens_used: i64, + token_budget: Option, +) -> crate::ThreadGoalStatus { + if status == crate::ThreadGoalStatus::Active + && token_budget.is_some_and(|budget| tokens_used >= budget) + { + crate::ThreadGoalStatus::BudgetLimited + } else { + status + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::test_support::test_thread_metadata; + use crate::runtime::test_support::unique_temp_dir; + use pretty_assertions::assert_eq; + + async fn test_runtime() -> std::sync::Arc { + StateRuntime::init(unique_temp_dir(), "test-provider".to_string()) + .await + .expect("state db should initialize") + } + + fn test_thread_id() -> ThreadId { + ThreadId::from_string("00000000-0000-0000-0000-000000000123").expect("valid thread id") + } + + async fn upsert_test_thread(runtime: &StateRuntime, thread_id: ThreadId) { + let metadata = test_thread_metadata( + runtime.codex_home(), + thread_id, + runtime.codex_home().join("workspace"), + ); + runtime + .upsert_thread(&metadata) + .await + .expect("test thread should be upserted"); + } + + #[tokio::test] + async fn replace_update_and_get_thread_goal() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + + let goal = runtime + .replace_thread_goal( + thread_id, + "optimize the benchmark", + crate::ThreadGoalStatus::Active, + /*token_budget*/ Some(100_000), + ) + .await + .expect("goal replacement should succeed"); + assert_eq!( + Some(goal.clone()), + runtime.get_thread_goal(thread_id).await.unwrap() + ); + + let updated = runtime + .update_thread_goal( + thread_id, + ThreadGoalUpdate { + status: Some(crate::ThreadGoalStatus::Paused), + token_budget: Some(Some(200_000)), + expected_goal_id: None, + }, + ) + .await + .expect("goal update should succeed") + .expect("goal should exist"); + let expected = crate::ThreadGoal { + status: crate::ThreadGoalStatus::Paused, + token_budget: Some(200_000), + updated_at: updated.updated_at, + ..goal.clone() + }; + assert_eq!(expected, updated); + + let replaced = runtime + .replace_thread_goal( + thread_id, + "ship the new result", + crate::ThreadGoalStatus::Active, + /*token_budget*/ None, + ) + .await + .expect("goal replacement should succeed"); + assert_eq!("ship the new result", replaced.objective); + assert_eq!(crate::ThreadGoalStatus::Active, replaced.status); + assert_eq!(None, replaced.token_budget); + assert_eq!(0, replaced.tokens_used); + assert_eq!(0, replaced.time_used_seconds); + + assert!(runtime.delete_thread_goal(thread_id).await.unwrap()); + assert_eq!(None, runtime.get_thread_goal(thread_id).await.unwrap()); + assert!(!runtime.delete_thread_goal(thread_id).await.unwrap()); + } + + #[tokio::test] + async fn replace_thread_goal_applies_budget_limit_immediately() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + + let replaced = runtime + .replace_thread_goal( + thread_id, + "stay within budget", + crate::ThreadGoalStatus::Active, + /*token_budget*/ Some(0), + ) + .await + .expect("goal replacement should succeed"); + + assert_eq!(crate::ThreadGoalStatus::BudgetLimited, replaced.status); + assert_eq!(Some(0), replaced.token_budget); + assert_eq!(0, replaced.tokens_used); + assert_eq!(0, replaced.time_used_seconds); + } + + #[tokio::test] + async fn insert_thread_goal_does_not_replace_existing_goal() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + + let inserted = runtime + .insert_thread_goal( + thread_id, + "optimize the benchmark", + crate::ThreadGoalStatus::Active, + /*token_budget*/ Some(100_000), + ) + .await + .expect("goal insertion should succeed") + .expect("goal should be inserted"); + + let duplicate = runtime + .insert_thread_goal( + thread_id, + "replace the benchmark", + crate::ThreadGoalStatus::Active, + /*token_budget*/ Some(200_000), + ) + .await + .expect("duplicate insert should not fail"); + + assert_eq!(None, duplicate); + assert_eq!( + Some(inserted), + runtime.get_thread_goal(thread_id).await.unwrap() + ); + } + + #[tokio::test] + async fn insert_thread_goal_applies_budget_limit_immediately() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + + let inserted = runtime + .insert_thread_goal( + thread_id, + "stay within budget", + crate::ThreadGoalStatus::Active, + /*token_budget*/ Some(0), + ) + .await + .expect("goal insertion should succeed") + .expect("goal should be inserted"); + + assert_eq!(crate::ThreadGoalStatus::BudgetLimited, inserted.status); + assert_eq!(Some(0), inserted.token_budget); + assert_eq!(0, inserted.tokens_used); + assert_eq!(0, inserted.time_used_seconds); + } + + #[tokio::test] + async fn update_thread_goal_ignores_replaced_goal_version() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + + let original = runtime + .replace_thread_goal( + thread_id, + "old objective", + crate::ThreadGoalStatus::Active, + /*token_budget*/ Some(100), + ) + .await + .expect("goal replacement should succeed"); + let replacement = runtime + .replace_thread_goal( + thread_id, + "new objective", + crate::ThreadGoalStatus::Active, + /*token_budget*/ Some(10), + ) + .await + .expect("goal replacement should succeed"); + + let stale_update = runtime + .update_thread_goal( + thread_id, + ThreadGoalUpdate { + status: Some(crate::ThreadGoalStatus::Complete), + token_budget: None, + expected_goal_id: Some(original.goal_id), + }, + ) + .await + .expect("goal update should succeed"); + + assert_eq!(None, stale_update); + assert_eq!( + Some(replacement.clone()), + runtime + .get_thread_goal(thread_id) + .await + .expect("goal read should succeed") + ); + + let fresh_update = runtime + .update_thread_goal( + thread_id, + ThreadGoalUpdate { + status: Some(crate::ThreadGoalStatus::Complete), + token_budget: None, + expected_goal_id: Some(replacement.goal_id), + }, + ) + .await + .expect("goal update should succeed") + .expect("fresh update should match the replacement goal"); + assert_eq!(crate::ThreadGoalStatus::Complete, fresh_update.status); + } + + #[tokio::test] + async fn usage_accounting_ignores_replaced_goal_version() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + + let original = runtime + .replace_thread_goal( + thread_id, + "old objective", + crate::ThreadGoalStatus::Active, + /*token_budget*/ Some(100), + ) + .await + .expect("goal replacement should succeed"); + let replacement = runtime + .replace_thread_goal( + thread_id, + "new objective", + crate::ThreadGoalStatus::Active, + /*token_budget*/ Some(10), + ) + .await + .expect("goal replacement should succeed"); + + let outcome = runtime + .account_thread_goal_usage( + thread_id, + /*time_delta_seconds*/ 5, + /*token_delta*/ 5, + ThreadGoalAccountingMode::ActiveOnly, + Some(original.goal_id.as_str()), + ) + .await + .expect("usage accounting should succeed"); + + let ThreadGoalAccountingOutcome::Unchanged(Some(goal)) = outcome else { + panic!("stale goal version should not be updated"); + }; + assert_ne!(replacement.goal_id, original.goal_id); + assert_eq!(replacement.created_at, goal.created_at); + assert_eq!("new objective", goal.objective); + assert_eq!(0, goal.tokens_used); + assert_eq!(0, goal.time_used_seconds); + } + + #[tokio::test] + async fn concurrent_partial_updates_preserve_independent_fields() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + runtime + .replace_thread_goal( + thread_id, + "optimize the benchmark", + crate::ThreadGoalStatus::Active, + /*token_budget*/ Some(100_000), + ) + .await + .expect("goal replacement should succeed"); + + let status_update = runtime.update_thread_goal( + thread_id, + ThreadGoalUpdate { + status: Some(crate::ThreadGoalStatus::Paused), + token_budget: None, + expected_goal_id: None, + }, + ); + let budget_update = runtime.update_thread_goal( + thread_id, + ThreadGoalUpdate { + status: None, + token_budget: Some(Some(200_000)), + expected_goal_id: None, + }, + ); + let (status_update, budget_update) = tokio::join!(status_update, budget_update); + status_update.expect("status update should succeed"); + budget_update.expect("budget update should succeed"); + + let goal = runtime + .get_thread_goal(thread_id) + .await + .expect("goal read should succeed") + .expect("goal should exist"); + assert_eq!(crate::ThreadGoalStatus::Paused, goal.status); + assert_eq!(Some(200_000), goal.token_budget); + } + + #[tokio::test] + async fn pause_active_thread_goal_does_not_clobber_terminal_status() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + let goal = runtime + .replace_thread_goal( + thread_id, + "optimize the benchmark", + crate::ThreadGoalStatus::Active, + /*token_budget*/ Some(100_000), + ) + .await + .expect("goal replacement should succeed"); + + let paused = runtime + .pause_active_thread_goal(thread_id) + .await + .expect("active pause should succeed") + .expect("active goal should be paused"); + let expected = crate::ThreadGoal { + status: crate::ThreadGoalStatus::Paused, + updated_at: paused.updated_at, + ..goal + }; + assert_eq!(expected, paused); + + let complete = runtime + .update_thread_goal( + thread_id, + ThreadGoalUpdate { + status: Some(crate::ThreadGoalStatus::Complete), + token_budget: None, + expected_goal_id: None, + }, + ) + .await + .expect("goal update should succeed") + .expect("goal should exist"); + let pause_result = runtime + .pause_active_thread_goal(thread_id) + .await + .expect("terminal pause attempt should succeed"); + assert_eq!(None, pause_result); + assert_eq!( + Some(complete), + runtime + .get_thread_goal(thread_id) + .await + .expect("goal read should succeed") + ); + } + + #[tokio::test] + async fn usage_accounting_updates_active_goals_and_accounts_budget_limited_in_flight_usage() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + runtime + .replace_thread_goal( + thread_id, + "stay within budget", + crate::ThreadGoalStatus::Active, + /*token_budget*/ Some(20), + ) + .await + .expect("goal replacement should succeed"); + + let outcome = runtime + .account_thread_goal_usage( + thread_id, + /*time_delta_seconds*/ 7, + /*token_delta*/ 5, + ThreadGoalAccountingMode::ActiveOnly, + /*expected_goal_id*/ None, + ) + .await + .expect("usage accounting should succeed"); + let ThreadGoalAccountingOutcome::Updated(goal) = outcome else { + panic!("active goal should be updated"); + }; + assert_eq!(crate::ThreadGoalStatus::Active, goal.status); + assert_eq!(5, goal.tokens_used); + assert_eq!(7, goal.time_used_seconds); + + let outcome = runtime + .account_thread_goal_usage( + thread_id, + /*time_delta_seconds*/ 3, + /*token_delta*/ 15, + ThreadGoalAccountingMode::ActiveOnly, + /*expected_goal_id*/ None, + ) + .await + .expect("usage accounting should succeed"); + let ThreadGoalAccountingOutcome::Updated(goal) = outcome else { + panic!("budget crossing should update the goal"); + }; + assert_eq!(crate::ThreadGoalStatus::BudgetLimited, goal.status); + assert_eq!(20, goal.tokens_used); + assert_eq!(10, goal.time_used_seconds); + + let outcome = runtime + .account_thread_goal_usage( + thread_id, + /*time_delta_seconds*/ 5, + /*token_delta*/ 5, + ThreadGoalAccountingMode::ActiveOnly, + /*expected_goal_id*/ None, + ) + .await + .expect("usage accounting should succeed"); + let ThreadGoalAccountingOutcome::Updated(goal) = outcome else { + panic!("budget-limited goal should still account in-flight active usage"); + }; + assert_eq!(crate::ThreadGoalStatus::BudgetLimited, goal.status); + assert_eq!(25, goal.tokens_used); + assert_eq!(15, goal.time_used_seconds); + } + + #[tokio::test] + async fn active_status_only_usage_accounting_does_not_update_budget_limited_goals() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + runtime + .replace_thread_goal( + thread_id, + "stay stopped", + crate::ThreadGoalStatus::BudgetLimited, + /*token_budget*/ Some(20), + ) + .await + .expect("goal replacement should succeed"); + + let outcome = runtime + .account_thread_goal_usage( + thread_id, + /*time_delta_seconds*/ 5, + /*token_delta*/ 5, + ThreadGoalAccountingMode::ActiveStatusOnly, + /*expected_goal_id*/ None, + ) + .await + .expect("usage accounting should succeed"); + let ThreadGoalAccountingOutcome::Unchanged(Some(goal)) = outcome else { + panic!("budget-limited goal should not be updated"); + }; + assert_eq!(crate::ThreadGoalStatus::BudgetLimited, goal.status); + assert_eq!(0, goal.tokens_used); + assert_eq!(0, goal.time_used_seconds); + } + + #[tokio::test] + async fn stopped_usage_accounting_promotes_paused_goal_over_budget() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + runtime + .replace_thread_goal( + thread_id, + "stop before overrun", + crate::ThreadGoalStatus::Active, + /*token_budget*/ Some(20), + ) + .await + .expect("goal replacement should succeed"); + runtime + .update_thread_goal( + thread_id, + crate::ThreadGoalUpdate { + status: Some(crate::ThreadGoalStatus::Paused), + token_budget: None, + expected_goal_id: None, + }, + ) + .await + .expect("goal update should succeed"); + + let outcome = runtime + .account_thread_goal_usage( + thread_id, + /*time_delta_seconds*/ 3, + /*token_delta*/ 25, + ThreadGoalAccountingMode::ActiveOrStopped, + /*expected_goal_id*/ None, + ) + .await + .expect("usage accounting should succeed"); + let ThreadGoalAccountingOutcome::Updated(goal) = outcome else { + panic!("stopped goal should account final usage"); + }; + assert_eq!(crate::ThreadGoalStatus::BudgetLimited, goal.status); + assert_eq!(25, goal.tokens_used); + assert_eq!(3, goal.time_used_seconds); + } + + #[tokio::test] + async fn budget_updates_immediately_stop_active_goals_already_over_budget() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + runtime + .replace_thread_goal( + thread_id, + "stay within budget", + crate::ThreadGoalStatus::Active, + /*token_budget*/ Some(100), + ) + .await + .expect("goal replacement should succeed"); + runtime + .account_thread_goal_usage( + thread_id, + /*time_delta_seconds*/ 1, + /*token_delta*/ 50, + ThreadGoalAccountingMode::ActiveOnly, + /*expected_goal_id*/ None, + ) + .await + .expect("usage accounting should succeed"); + + let lowered = runtime + .update_thread_goal( + thread_id, + ThreadGoalUpdate { + status: None, + token_budget: Some(Some(40)), + expected_goal_id: None, + }, + ) + .await + .expect("goal update should succeed") + .expect("goal should exist"); + + assert_eq!(crate::ThreadGoalStatus::BudgetLimited, lowered.status); + assert_eq!(Some(40), lowered.token_budget); + assert_eq!(50, lowered.tokens_used); + } + + #[tokio::test] + async fn activating_goal_already_over_budget_keeps_it_budget_limited() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + runtime + .replace_thread_goal( + thread_id, + "stay within budget", + crate::ThreadGoalStatus::Active, + /*token_budget*/ Some(40), + ) + .await + .expect("goal replacement should succeed"); + runtime + .account_thread_goal_usage( + thread_id, + /*time_delta_seconds*/ 1, + /*token_delta*/ 50, + ThreadGoalAccountingMode::ActiveOnly, + /*expected_goal_id*/ None, + ) + .await + .expect("usage accounting should succeed"); + + let reactivated = runtime + .update_thread_goal( + thread_id, + ThreadGoalUpdate { + status: Some(crate::ThreadGoalStatus::Active), + token_budget: None, + expected_goal_id: None, + }, + ) + .await + .expect("goal update should succeed") + .expect("goal should exist"); + + assert_eq!(crate::ThreadGoalStatus::BudgetLimited, reactivated.status); + assert_eq!(Some(40), reactivated.token_budget); + assert_eq!(50, reactivated.tokens_used); + } + + #[tokio::test] + async fn pausing_budget_limited_goal_preserves_terminal_status() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + runtime + .replace_thread_goal( + thread_id, + "stay within budget", + crate::ThreadGoalStatus::Active, + /*token_budget*/ Some(40), + ) + .await + .expect("goal replacement should succeed"); + runtime + .account_thread_goal_usage( + thread_id, + /*time_delta_seconds*/ 1, + /*token_delta*/ 50, + ThreadGoalAccountingMode::ActiveOnly, + /*expected_goal_id*/ None, + ) + .await + .expect("usage accounting should succeed"); + + let paused = runtime + .update_thread_goal( + thread_id, + ThreadGoalUpdate { + status: Some(crate::ThreadGoalStatus::Paused), + token_budget: None, + expected_goal_id: None, + }, + ) + .await + .expect("goal update should succeed") + .expect("goal should exist"); + + assert_eq!(crate::ThreadGoalStatus::BudgetLimited, paused.status); + assert_eq!(Some(40), paused.token_budget); + assert_eq!(50, paused.tokens_used); + } + + #[tokio::test] + async fn usage_accounting_can_finalize_completed_goal_for_completing_turn() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + runtime + .replace_thread_goal( + thread_id, + "finish the report", + crate::ThreadGoalStatus::Complete, + /*token_budget*/ Some(1_000), + ) + .await + .expect("goal replacement should succeed"); + + let active_only = runtime + .account_thread_goal_usage( + thread_id, + /*time_delta_seconds*/ 30, + /*token_delta*/ 200, + ThreadGoalAccountingMode::ActiveOnly, + /*expected_goal_id*/ None, + ) + .await + .expect("usage accounting should succeed"); + let ThreadGoalAccountingOutcome::Unchanged(Some(goal)) = active_only else { + panic!("completed goal should not be updated by active-only accounting"); + }; + assert_eq!(crate::ThreadGoalStatus::Complete, goal.status); + assert_eq!(0, goal.tokens_used); + assert_eq!(0, goal.time_used_seconds); + + let completing_turn = runtime + .account_thread_goal_usage( + thread_id, + /*time_delta_seconds*/ 30, + /*token_delta*/ 200, + ThreadGoalAccountingMode::ActiveOrComplete, + /*expected_goal_id*/ None, + ) + .await + .expect("usage accounting should succeed"); + let ThreadGoalAccountingOutcome::Updated(goal) = completing_turn else { + panic!("completed goal should be updated for final accounting"); + }; + assert_eq!(crate::ThreadGoalStatus::Complete, goal.status); + assert_eq!(200, goal.tokens_used); + assert_eq!(30, goal.time_used_seconds); + } + + #[tokio::test] + async fn usage_accounting_can_finalize_stopped_goal_for_in_flight_turn() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + runtime + .replace_thread_goal( + thread_id, + "finish the report", + crate::ThreadGoalStatus::Active, + /*token_budget*/ Some(1_000), + ) + .await + .expect("goal replacement should succeed"); + runtime + .update_thread_goal( + thread_id, + ThreadGoalUpdate { + status: Some(crate::ThreadGoalStatus::Paused), + token_budget: None, + expected_goal_id: None, + }, + ) + .await + .expect("goal update should succeed") + .expect("goal should exist"); + + let active_only = runtime + .account_thread_goal_usage( + thread_id, + /*time_delta_seconds*/ 30, + /*token_delta*/ 200, + ThreadGoalAccountingMode::ActiveOnly, + /*expected_goal_id*/ None, + ) + .await + .expect("usage accounting should succeed"); + let ThreadGoalAccountingOutcome::Unchanged(Some(goal)) = active_only else { + panic!("paused goal should not be updated by active-only accounting"); + }; + assert_eq!(crate::ThreadGoalStatus::Paused, goal.status); + assert_eq!(0, goal.tokens_used); + assert_eq!(0, goal.time_used_seconds); + + let in_flight_turn = runtime + .account_thread_goal_usage( + thread_id, + /*time_delta_seconds*/ 30, + /*token_delta*/ 200, + ThreadGoalAccountingMode::ActiveOrStopped, + /*expected_goal_id*/ None, + ) + .await + .expect("usage accounting should succeed"); + let ThreadGoalAccountingOutcome::Updated(goal) = in_flight_turn else { + panic!("stopped goal should be updated for in-flight accounting"); + }; + assert_eq!(crate::ThreadGoalStatus::Paused, goal.status); + assert_eq!(200, goal.tokens_used); + assert_eq!(30, goal.time_used_seconds); + } + + #[tokio::test] + async fn usage_accounting_adds_concurrent_token_deltas() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + runtime + .replace_thread_goal( + thread_id, + "count every token", + crate::ThreadGoalStatus::Active, + /*token_budget*/ Some(1_000), + ) + .await + .expect("goal replacement should succeed"); + + let first = runtime.account_thread_goal_usage( + thread_id, + /*time_delta_seconds*/ 4, + /*token_delta*/ 40, + ThreadGoalAccountingMode::ActiveOnly, + /*expected_goal_id*/ None, + ); + let second = runtime.account_thread_goal_usage( + thread_id, + /*time_delta_seconds*/ 6, + /*token_delta*/ 60, + ThreadGoalAccountingMode::ActiveOnly, + /*expected_goal_id*/ None, + ); + let (first, second) = tokio::join!(first, second); + first.expect("first usage accounting should succeed"); + second.expect("second usage accounting should succeed"); + + let goal = runtime + .get_thread_goal(thread_id) + .await + .expect("goal read should succeed") + .expect("goal should exist"); + assert_eq!(100, goal.tokens_used); + assert_eq!(10, goal.time_used_seconds); + } + + #[tokio::test] + async fn deleting_thread_deletes_goal() { + let runtime = test_runtime().await; + let thread_id = test_thread_id(); + upsert_test_thread(&runtime, thread_id).await; + runtime + .replace_thread_goal( + thread_id, + "clean up with the thread", + crate::ThreadGoalStatus::Active, + /*token_budget*/ None, + ) + .await + .expect("goal replacement should succeed"); + + runtime + .delete_thread(thread_id) + .await + .expect("thread deletion should succeed"); + + assert_eq!( + None, + runtime + .get_thread_goal(thread_id) + .await + .expect("goal read should succeed") + ); + } +} From 6c874f9b341a8fcb01bdc35999aad1ca093afea2 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 24 Apr 2026 20:53:41 -0700 Subject: [PATCH 14/67] Add goal app-server API (2 / 5) (#18074) Adds the app-server v2 goal API on top of the persisted goal state from PR 1. ## Why Clients need a stable app-server surface for reading and controlling materialized thread goals before the model tools and TUI can use them. Goal changes also need to be observable by app-server clients, including clients that resume an existing thread. ## What changed - Added v2 `thread/goal/get`, `thread/goal/set`, and `thread/goal/clear` RPCs for materialized threads. - Added `thread/goal/updated` and `thread/goal/cleared` notifications so clients can keep local goal state in sync. - Added resume/snapshot wiring so reconnecting clients see the current goal state for a thread. - Added app-server handlers that reconcile persisted rollout state before direct goal mutations. - Updated the app-server README plus generated JSON and TypeScript schema fixtures for the new API surface. ## Verification - Added app-server v2 coverage for goal get/set/clear behavior, notification emission, resume snapshots, and non-local thread-store interactions. --- .../schema/json/ClientRequest.json | 9 + .../schema/json/ServerNotification.json | 127 +++++ .../codex_app_server_protocol.schemas.json | 131 +++++ .../codex_app_server_protocol.v2.schemas.json | 131 +++++ .../v2/ThreadGoalClearedNotification.json | 13 + .../v2/ThreadGoalUpdatedNotification.json | 80 +++ .../schema/typescript/ServerNotification.ts | 4 +- .../schema/typescript/v2/ThreadGoal.ts | 6 + .../v2/ThreadGoalClearedNotification.ts | 5 + .../schema/typescript/v2/ThreadGoalStatus.ts | 5 + .../v2/ThreadGoalUpdatedNotification.ts | 6 + .../schema/typescript/v2/index.ts | 4 + .../src/protocol/common.rs | 89 ++++ .../app-server-protocol/src/protocol/v2.rs | 114 +++++ codex-rs/app-server/README.md | 69 +++ .../app-server/src/bespoke_event_handling.rs | 15 + .../app-server/src/codex_message_processor.rs | 131 +++++ .../thread_goal_handlers.rs | 466 ++++++++++++++++++ codex-rs/app-server/src/thread_state.rs | 14 + codex-rs/app-server/src/transport/mod.rs | 98 ++++ .../tests/suite/v2/thread_resume.rs | 391 +++++++++++++++ codex-rs/core/src/agent/status.rs | 3 +- codex-rs/core/src/session/turn.rs | 1 + codex-rs/mcp-server/src/codex_tool_runner.rs | 3 + codex-rs/protocol/src/protocol.rs | 41 ++ codex-rs/rollout-trace/src/protocol_event.rs | 9 +- codex-rs/rollout/src/policy.rs | 1 + codex-rs/tui/src/app/app_server_adapter.rs | 6 + codex-rs/tui/src/chatwidget.rs | 6 + 29 files changed, 1973 insertions(+), 5 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ThreadGoalClearedNotification.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoal.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalClearedNotification.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalUpdatedNotification.ts create mode 100644 codex-rs/app-server/src/codex_message_processor/thread_goal_handlers.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index f895d3fe77..f34ee28976 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -3391,6 +3391,15 @@ ], "type": "object" }, + "ThreadGoalStatus": { + "enum": [ + "active", + "paused", + "budgetLimited", + "complete" + ], + "type": "string" + }, "ThreadInjectItemsParams": { "properties": { "items": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 59b3f5b45a..629c0b97fa 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -3028,6 +3028,93 @@ ], "type": "object" }, + "ThreadGoal": { + "properties": { + "createdAt": { + "format": "int64", + "type": "integer" + }, + "objective": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/ThreadGoalStatus" + }, + "threadId": { + "type": "string" + }, + "timeUsedSeconds": { + "format": "int64", + "type": "integer" + }, + "tokenBudget": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "tokensUsed": { + "format": "int64", + "type": "integer" + }, + "updatedAt": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "createdAt", + "objective", + "status", + "threadId", + "timeUsedSeconds", + "tokensUsed", + "updatedAt" + ], + "type": "object" + }, + "ThreadGoalClearedNotification": { + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "type": "object" + }, + "ThreadGoalStatus": { + "enum": [ + "active", + "paused", + "budgetLimited", + "complete" + ], + "type": "string" + }, + "ThreadGoalUpdatedNotification": { + "properties": { + "goal": { + "$ref": "#/definitions/ThreadGoal" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "goal", + "threadId" + ], + "type": "object" + }, "ThreadId": { "type": "string" }, @@ -4727,6 +4814,46 @@ "title": "Thread/name/updatedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/goal/updated" + ], + "title": "Thread/goal/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadGoalUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/goal/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/goal/cleared" + ], + "title": "Thread/goal/clearedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadGoalClearedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/goal/clearedNotification", + "type": "object" + }, { "properties": { "method": { 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 0c76232d91..2fc1be3469 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 @@ -3806,6 +3806,46 @@ "title": "Thread/name/updatedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/goal/updated" + ], + "title": "Thread/goal/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadGoalUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/goal/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/goal/cleared" + ], + "title": "Thread/goal/clearedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ThreadGoalClearedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/goal/clearedNotification", + "type": "object" + }, { "properties": { "method": { @@ -14611,6 +14651,97 @@ "title": "ThreadForkResponse", "type": "object" }, + "ThreadGoal": { + "properties": { + "createdAt": { + "format": "int64", + "type": "integer" + }, + "objective": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/v2/ThreadGoalStatus" + }, + "threadId": { + "type": "string" + }, + "timeUsedSeconds": { + "format": "int64", + "type": "integer" + }, + "tokenBudget": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "tokensUsed": { + "format": "int64", + "type": "integer" + }, + "updatedAt": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "createdAt", + "objective", + "status", + "threadId", + "timeUsedSeconds", + "tokensUsed", + "updatedAt" + ], + "type": "object" + }, + "ThreadGoalClearedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadGoalClearedNotification", + "type": "object" + }, + "ThreadGoalStatus": { + "enum": [ + "active", + "paused", + "budgetLimited", + "complete" + ], + "type": "string" + }, + "ThreadGoalUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "goal": { + "$ref": "#/definitions/v2/ThreadGoal" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "goal", + "threadId" + ], + "title": "ThreadGoalUpdatedNotification", + "type": "object" + }, "ThreadId": { "type": "string" }, 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 55f33badd0..87e133a07a 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 @@ -10444,6 +10444,46 @@ "title": "Thread/name/updatedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "thread/goal/updated" + ], + "title": "Thread/goal/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadGoalUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/goal/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": [ + "thread/goal/cleared" + ], + "title": "Thread/goal/clearedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadGoalClearedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Thread/goal/clearedNotification", + "type": "object" + }, { "properties": { "method": { @@ -12497,6 +12537,97 @@ "title": "ThreadForkResponse", "type": "object" }, + "ThreadGoal": { + "properties": { + "createdAt": { + "format": "int64", + "type": "integer" + }, + "objective": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/ThreadGoalStatus" + }, + "threadId": { + "type": "string" + }, + "timeUsedSeconds": { + "format": "int64", + "type": "integer" + }, + "tokenBudget": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "tokensUsed": { + "format": "int64", + "type": "integer" + }, + "updatedAt": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "createdAt", + "objective", + "status", + "threadId", + "timeUsedSeconds", + "tokensUsed", + "updatedAt" + ], + "type": "object" + }, + "ThreadGoalClearedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadGoalClearedNotification", + "type": "object" + }, + "ThreadGoalStatus": { + "enum": [ + "active", + "paused", + "budgetLimited", + "complete" + ], + "type": "string" + }, + "ThreadGoalUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "goal": { + "$ref": "#/definitions/ThreadGoal" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "goal", + "threadId" + ], + "title": "ThreadGoalUpdatedNotification", + "type": "object" + }, "ThreadId": { "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalClearedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalClearedNotification.json new file mode 100644 index 0000000000..c1fe94b910 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalClearedNotification.json @@ -0,0 +1,13 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "title": "ThreadGoalClearedNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json new file mode 100644 index 0000000000..52a2e905a2 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadGoalUpdatedNotification.json @@ -0,0 +1,80 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ThreadGoal": { + "properties": { + "createdAt": { + "format": "int64", + "type": "integer" + }, + "objective": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/ThreadGoalStatus" + }, + "threadId": { + "type": "string" + }, + "timeUsedSeconds": { + "format": "int64", + "type": "integer" + }, + "tokenBudget": { + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "tokensUsed": { + "format": "int64", + "type": "integer" + }, + "updatedAt": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "createdAt", + "objective", + "status", + "threadId", + "timeUsedSeconds", + "tokensUsed", + "updatedAt" + ], + "type": "object" + }, + "ThreadGoalStatus": { + "enum": [ + "active", + "paused", + "budgetLimited", + "complete" + ], + "type": "string" + } + }, + "properties": { + "goal": { + "$ref": "#/definitions/ThreadGoal" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "goal", + "threadId" + ], + "title": "ThreadGoalUpdatedNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index 031527e3ad..41d4754bc3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -40,6 +40,8 @@ import type { SkillsChangedNotification } from "./v2/SkillsChangedNotification"; import type { TerminalInteractionNotification } from "./v2/TerminalInteractionNotification"; import type { ThreadArchivedNotification } from "./v2/ThreadArchivedNotification"; import type { ThreadClosedNotification } from "./v2/ThreadClosedNotification"; +import type { ThreadGoalClearedNotification } from "./v2/ThreadGoalClearedNotification"; +import type { ThreadGoalUpdatedNotification } from "./v2/ThreadGoalUpdatedNotification"; import type { ThreadNameUpdatedNotification } from "./v2/ThreadNameUpdatedNotification"; import type { ThreadRealtimeClosedNotification } from "./v2/ThreadRealtimeClosedNotification"; import type { ThreadRealtimeErrorNotification } from "./v2/ThreadRealtimeErrorNotification"; @@ -64,4 +66,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoal.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoal.ts new file mode 100644 index 0000000000..c68732324f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoal.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ThreadGoalStatus } from "./ThreadGoalStatus"; + +export type ThreadGoal = { threadId: string, objective: string, status: ThreadGoalStatus, tokenBudget: number | null, tokensUsed: number, timeUsedSeconds: number, createdAt: number, updatedAt: number, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalClearedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalClearedNotification.ts new file mode 100644 index 0000000000..e8e5a8b6e0 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalClearedNotification.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 ThreadGoalClearedNotification = { threadId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.ts new file mode 100644 index 0000000000..7a4bf332fb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalStatus.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 ThreadGoalStatus = "active" | "paused" | "budgetLimited" | "complete"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalUpdatedNotification.ts new file mode 100644 index 0000000000..c9972afa84 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadGoalUpdatedNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ThreadGoal } from "./ThreadGoal"; + +export type ThreadGoalUpdatedNotification = { threadId: string, turnId: string | null, goal: ThreadGoal, }; 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 59c4fa6734..0e43b5a4b7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -326,6 +326,10 @@ export type { ThreadCompactStartParams } from "./ThreadCompactStartParams"; export type { ThreadCompactStartResponse } from "./ThreadCompactStartResponse"; export type { ThreadForkParams } from "./ThreadForkParams"; export type { ThreadForkResponse } from "./ThreadForkResponse"; +export type { ThreadGoal } from "./ThreadGoal"; +export type { ThreadGoalClearedNotification } from "./ThreadGoalClearedNotification"; +export type { ThreadGoalStatus } from "./ThreadGoalStatus"; +export type { ThreadGoalUpdatedNotification } from "./ThreadGoalUpdatedNotification"; export type { ThreadInjectItemsParams } from "./ThreadInjectItemsParams"; export type { ThreadInjectItemsResponse } from "./ThreadInjectItemsResponse"; export type { ThreadItem } from "./ThreadItem"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 1c5be70da5..016d6e16b8 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -285,6 +285,21 @@ client_request_definitions! { params: v2::ThreadSetNameParams, response: v2::ThreadSetNameResponse, }, + #[experimental("thread/goal/set")] + ThreadGoalSet => "thread/goal/set" { + params: v2::ThreadGoalSetParams, + response: v2::ThreadGoalSetResponse, + }, + #[experimental("thread/goal/get")] + ThreadGoalGet => "thread/goal/get" { + params: v2::ThreadGoalGetParams, + response: v2::ThreadGoalGetResponse, + }, + #[experimental("thread/goal/clear")] + ThreadGoalClear => "thread/goal/clear" { + params: v2::ThreadGoalClearParams, + response: v2::ThreadGoalClearResponse, + }, ThreadMetadataUpdate => "thread/metadata/update" { params: v2::ThreadMetadataUpdateParams, response: v2::ThreadMetadataUpdateResponse, @@ -1027,6 +1042,10 @@ server_notification_definitions! { ThreadClosed => "thread/closed" (v2::ThreadClosedNotification), SkillsChanged => "skills/changed" (v2::SkillsChangedNotification), ThreadNameUpdated => "thread/name/updated" (v2::ThreadNameUpdatedNotification), + #[experimental("thread/goal/updated")] + ThreadGoalUpdated => "thread/goal/updated" (v2::ThreadGoalUpdatedNotification), + #[experimental("thread/goal/cleared")] + ThreadGoalCleared => "thread/goal/cleared" (v2::ThreadGoalClearedNotification), ThreadTokenUsageUpdated => "thread/tokenUsage/updated" (v2::ThreadTokenUsageUpdatedNotification), TurnStarted => "turn/started" (v2::TurnStartedNotification), HookStarted => "hook/started" (v2::HookStartedNotification), @@ -2046,6 +2065,76 @@ mod tests { let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request); assert_eq!(reason, Some("thread/realtime/start")); } + + #[test] + fn thread_goal_methods_are_marked_experimental() { + let set_request = ClientRequest::ThreadGoalSet { + request_id: RequestId::Integer(1), + params: v2::ThreadGoalSetParams { + thread_id: "thr_123".to_string(), + objective: Some("ship goal mode".to_string()), + status: Some(v2::ThreadGoalStatus::Active), + token_budget: Some(Some(10_000)), + }, + }; + let get_request = ClientRequest::ThreadGoalGet { + request_id: RequestId::Integer(2), + params: v2::ThreadGoalGetParams { + thread_id: "thr_123".to_string(), + }, + }; + let clear_request = ClientRequest::ThreadGoalClear { + request_id: RequestId::Integer(3), + params: v2::ThreadGoalClearParams { + thread_id: "thr_123".to_string(), + }, + }; + + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&set_request), + Some("thread/goal/set") + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&get_request), + Some("thread/goal/get") + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&clear_request), + Some("thread/goal/clear") + ); + } + + #[test] + fn thread_goal_notifications_are_marked_experimental() { + let goal = v2::ThreadGoal { + thread_id: "thr_123".to_string(), + objective: "ship goal mode".to_string(), + status: v2::ThreadGoalStatus::Active, + token_budget: Some(10_000), + tokens_used: 123, + time_used_seconds: 45, + created_at: 1_700_000_000, + updated_at: 1_700_000_123, + }; + let updated = ServerNotification::ThreadGoalUpdated(v2::ThreadGoalUpdatedNotification { + thread_id: "thr_123".to_string(), + turn_id: None, + goal, + }); + let cleared = ServerNotification::ThreadGoalCleared(v2::ThreadGoalClearedNotification { + thread_id: "thr_123".to_string(), + }); + + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&updated), + Some("thread/goal/updated") + ); + assert_eq!( + crate::experimental_api::ExperimentalApi::experimental_reason(&cleared), + Some("thread/goal/cleared") + ); + } + #[test] fn thread_realtime_started_notification_is_marked_experimental() { let notification = diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 505102e128..b7dccc8613 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -96,6 +96,7 @@ use codex_protocol::protocol::SkillMetadata as CoreSkillMetadata; use codex_protocol::protocol::SkillScope as CoreSkillScope; use codex_protocol::protocol::SkillToolDependency as CoreSkillToolDependency; use codex_protocol::protocol::SubAgentSource as CoreSubAgentSource; +use codex_protocol::protocol::ThreadGoalStatus as CoreThreadGoalStatus; use codex_protocol::protocol::TokenUsage as CoreTokenUsage; use codex_protocol::protocol::TokenUsageInfo as CoreTokenUsageInfo; use codex_protocol::request_permissions::PermissionGrantScope as CorePermissionGrantScope; @@ -3747,6 +3748,103 @@ pub struct ThreadUnarchiveParams { #[ts(export_to = "v2/")] pub struct ThreadSetNameResponse {} +v2_enum_from_core! { + pub enum ThreadGoalStatus from CoreThreadGoalStatus { + Active, + Paused, + BudgetLimited, + Complete, + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoal { + pub thread_id: String, + pub objective: String, + pub status: ThreadGoalStatus, + #[ts(type = "number | null")] + pub token_budget: Option, + #[ts(type = "number")] + pub tokens_used: i64, + #[ts(type = "number")] + pub time_used_seconds: i64, + #[ts(type = "number")] + pub created_at: i64, + #[ts(type = "number")] + pub updated_at: i64, +} + +impl From for ThreadGoal { + fn from(value: codex_protocol::protocol::ThreadGoal) -> Self { + Self { + thread_id: value.thread_id.to_string(), + objective: value.objective, + status: value.status.into(), + token_budget: value.token_budget, + tokens_used: value.tokens_used, + time_used_seconds: value.time_used_seconds, + created_at: value.created_at, + updated_at: value.updated_at, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalSetParams { + pub thread_id: String, + #[ts(optional = nullable)] + pub objective: Option, + #[ts(optional = nullable)] + pub status: Option, + #[serde( + default, + deserialize_with = "super::serde_helpers::deserialize_double_option", + serialize_with = "super::serde_helpers::serialize_double_option", + skip_serializing_if = "Option::is_none" + )] + #[ts(optional = nullable, type = "number | null")] + pub token_budget: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalSetResponse { + pub goal: ThreadGoal, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalGetParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalGetResponse { + pub goal: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalClearParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalClearResponse { + pub cleared: bool, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -6270,6 +6368,22 @@ pub struct ThreadNameUpdatedNotification { pub thread_name: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalUpdatedNotification { + pub thread_id: String, + pub turn_id: Option, + pub goal: ThreadGoal, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadGoalClearedNotification { + pub thread_id: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 1744178056..35df7016c4 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -152,6 +152,11 @@ Example with notification opt-out: - `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`. - `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success. - `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success. +- `thread/goal/set` — create, replace, or update the single persisted goal for a materialized thread; returns the current goal and emits `thread/goal/updated`. Supplying a new `objective` replaces the goal and resets usage accounting. Supplying the current non-terminal objective or omitting `objective` updates the existing goal’s status and/or token budget while preserving usage. +- `thread/goal/get` — fetch the current persisted goal for a materialized thread; returns `goal: null` when no goal exists. +- `thread/goal/clear` — clear the current persisted goal for a materialized thread; returns whether a goal was removed and emits `thread/goal/cleared` when state changes. +- `thread/goal/updated` — notification emitted whenever a thread goal changes; includes the full current goal. +- `thread/goal/cleared` — notification emitted whenever a thread goal is removed. - `thread/status/changed` — notification emitted when a loaded thread’s status changes (`threadId` + new `status`). - `thread/archive` — move a thread’s rollout file into the archived directory and attempt to move any spawned descendant thread rollout files; returns `{}` on success and emits `thread/archived` for each archived thread. - `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server keeps the thread loaded and unloads it only after it has had no subscribers and no thread activity for 30 minutes, then emits `thread/closed`. @@ -470,6 +475,70 @@ Experimental: use `memory/reset` to clear local memory artifacts and sqlite-back { "id": 27, "result": {} } ``` +### Example: Set and update a thread goal + +Use `thread/goal/set` with an `objective` to create or replace the current goal for a materialized thread. Supplying a new objective resets `tokensUsed`, `timeUsedSeconds`, and `createdAt`. Supplying the current non-terminal objective, or omitting `objective`, updates the existing goal’s status or token budget while preserving usage history. Clients can set `budgetLimited` when they stop because a token budget is exhausted or nearly exhausted; the system also sets it when accounting crosses a configured token budget. + +```json +{ "method": "thread/goal/set", "id": 27, "params": { + "threadId": "thr_123", + "objective": "Keep improving the benchmark until p95 latency is under 120ms", + "tokenBudget": 200000 +} } +{ "id": 27, "result": { "goal": { + "threadId": "thr_123", + "objective": "Keep improving the benchmark until p95 latency is under 120ms", + "status": "active", + "tokenBudget": 200000, + "tokensUsed": 0, + "timeUsedSeconds": 0, + "createdAt": 1776272400, + "updatedAt": 1776272400 +} } } +{ "method": "thread/goal/updated", "params": { "threadId": "thr_123", "goal": { + "threadId": "thr_123", + "objective": "Keep improving the benchmark until p95 latency is under 120ms", + "status": "active", + "tokenBudget": 200000, + "tokensUsed": 0, + "timeUsedSeconds": 0, + "createdAt": 1776272400, + "updatedAt": 1776272400 +} } } +``` + +```json +{ "method": "thread/goal/set", "id": 28, "params": { + "threadId": "thr_123", + "status": "paused" +} } +{ "id": 28, "result": { "goal": { + "threadId": "thr_123", + "objective": "Keep improving the benchmark until p95 latency is under 120ms", + "status": "paused", + "tokenBudget": 200000, + "tokensUsed": 10000, + "timeUsedSeconds": 60, + "createdAt": 1776272400, + "updatedAt": 1776272460 +} } } +``` + +Use `thread/goal/get` to read the current goal without changing it. + +```json +{ "method": "thread/goal/get", "id": 29, "params": { "threadId": "thr_123" } } +{ "id": 29, "result": { "goal": null } } +``` + +Use `thread/goal/clear` to remove the current goal. + +```json +{ "method": "thread/goal/clear", "id": 30, "params": { "threadId": "thr_123" } } +{ "id": 30, "result": { "cleared": true } } +{ "method": "thread/goal/cleared", "params": { "threadId": "thr_123" } } +``` + ### Example: Archive a thread Use `thread/archive` to move the persisted rollout (stored as a JSONL file on disk) into the archived sessions directory and attempt to move any spawned descendant thread rollouts. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 8af3f87615..a1eba990c6 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -78,6 +78,7 @@ use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::SkillsChangedNotification; use codex_app_server_protocol::TerminalInteractionNotification; +use codex_app_server_protocol::ThreadGoalUpdatedNotification; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadNameUpdatedNotification; use codex_app_server_protocol::ThreadRealtimeClosedNotification; @@ -1954,6 +1955,20 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } + EventMsg::ThreadGoalUpdated(thread_goal_event) => { + if let ApiVersion::V2 = api_version { + let notification = ThreadGoalUpdatedNotification { + thread_id: thread_goal_event.thread_id.to_string(), + turn_id: thread_goal_event.turn_id, + goal: thread_goal_event.goal.clone().into(), + }; + outgoing + .send_global_server_notification(ServerNotification::ThreadGoalUpdated( + notification, + )) + .await; + } + } EventMsg::TurnDiff(turn_diff_event) => { handle_turn_diff( conversation_id, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index bf6b4bdf93..dfec182fd0 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -150,6 +150,16 @@ use codex_app_server_protocol::ThreadDecrementElicitationParams; use codex_app_server_protocol::ThreadDecrementElicitationResponse; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; +use codex_app_server_protocol::ThreadGoal; +use codex_app_server_protocol::ThreadGoalClearParams; +use codex_app_server_protocol::ThreadGoalClearResponse; +use codex_app_server_protocol::ThreadGoalClearedNotification; +use codex_app_server_protocol::ThreadGoalGetParams; +use codex_app_server_protocol::ThreadGoalGetResponse; +use codex_app_server_protocol::ThreadGoalSetParams; +use codex_app_server_protocol::ThreadGoalSetResponse; +use codex_app_server_protocol::ThreadGoalStatus; +use codex_app_server_protocol::ThreadGoalUpdatedNotification; use codex_app_server_protocol::ThreadIncrementElicitationParams; use codex_app_server_protocol::ThreadIncrementElicitationResponse; use codex_app_server_protocol::ThreadInjectItemsParams; @@ -482,6 +492,9 @@ enum ThreadReadViewError { Internal(String), } +mod thread_goal_handlers; +use self::thread_goal_handlers::api_thread_goal_from_state; + impl Drop for ActiveLogin { fn drop(&mut self) { self.cancel(); @@ -955,6 +968,18 @@ impl CodexMessageProcessor { self.thread_set_name(to_connection_request_id(request_id), params) .await; } + ClientRequest::ThreadGoalSet { request_id, params } => { + self.thread_goal_set(to_connection_request_id(request_id), params) + .await; + } + ClientRequest::ThreadGoalGet { request_id, params } => { + self.thread_goal_get(to_connection_request_id(request_id), params) + .await; + } + ClientRequest::ThreadGoalClear { request_id, params } => { + self.thread_goal_clear(to_connection_request_id(request_id), params) + .await; + } ClientRequest::ThreadMetadataUpdate { request_id, params } => { self.thread_metadata_update(to_connection_request_id(request_id), params) .await; @@ -4695,6 +4720,9 @@ impl CodexMessageProcessor { ) .await; } + if self.config.features.enabled(Feature::Goals) { + self.emit_thread_goal_snapshot(thread_id).await; + } } Err(err) => { let error = JSONRPCErrorError { @@ -4860,6 +4888,17 @@ impl CodexMessageProcessor { return true; }; + let emit_thread_goal_update = self.config.features.enabled(Feature::Goals); + let thread_goal_state_db = if emit_thread_goal_update { + if let Some(state_db) = existing_thread.state_db() { + Some(state_db) + } else { + open_state_db_for_direct_thread_lookup(&self.config).await + } + } else { + None + }; + let command = crate::thread_state::ThreadListenerCommand::SendThreadResumeResponse( Box::new(crate::thread_state::PendingThreadResumeRequest { request_id: request_id.clone(), @@ -4867,6 +4906,8 @@ impl CodexMessageProcessor { config_snapshot, instruction_sources, thread_summary, + emit_thread_goal_update, + thread_goal_state_db, include_turns: !params.exclude_turns, }), ); @@ -4879,6 +4920,7 @@ impl CodexMessageProcessor { data: None, }; self.outgoing.send_error(request_id, err).await; + return true; } return true; } @@ -8800,6 +8842,29 @@ async fn handle_thread_listener_command( ) .await; } + ThreadListenerCommand::EmitThreadGoalUpdated { goal } => { + outgoing + .send_server_notification(ServerNotification::ThreadGoalUpdated( + ThreadGoalUpdatedNotification { + thread_id: conversation_id.to_string(), + turn_id: None, + goal, + }, + )) + .await; + } + ThreadListenerCommand::EmitThreadGoalCleared => { + outgoing + .send_server_notification(ServerNotification::ThreadGoalCleared( + ThreadGoalClearedNotification { + thread_id: conversation_id.to_string(), + }, + )) + .await; + } + ThreadListenerCommand::EmitThreadGoalSnapshot { state_db } => { + send_thread_goal_snapshot_notification(outgoing, conversation_id, &state_db).await; + } ThreadListenerCommand::ResolveServerRequest { request_id, completion_tx, @@ -8964,11 +9029,56 @@ async fn handle_pending_thread_resume_request( ) .await; } + if pending.emit_thread_goal_update { + if let Some(state_db) = pending.thread_goal_state_db { + send_thread_goal_snapshot_notification(outgoing, conversation_id, &state_db).await; + } else { + tracing::warn!( + thread_id = %conversation_id, + "state db unavailable when reading thread goal for running thread resume" + ); + } + } outgoing .replay_requests_to_connection_for_thread(connection_id, conversation_id) .await; } +async fn send_thread_goal_snapshot_notification( + outgoing: &Arc, + thread_id: ThreadId, + state_db: &StateDbHandle, +) { + match state_db.get_thread_goal(thread_id).await { + Ok(Some(goal)) => { + outgoing + .send_server_notification(ServerNotification::ThreadGoalUpdated( + ThreadGoalUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: None, + goal: api_thread_goal_from_state(goal), + }, + )) + .await; + } + Ok(None) => { + outgoing + .send_server_notification(ServerNotification::ThreadGoalCleared( + ThreadGoalClearedNotification { + thread_id: thread_id.to_string(), + }, + )) + .await; + } + Err(err) => { + tracing::warn!( + thread_id = %thread_id, + "failed to read thread goal for resume snapshot: {err}" + ); + } + } +} + enum ThreadTurnSource<'a> { HistoryItems(&'a [RolloutItem]), } @@ -9459,6 +9569,27 @@ async fn open_state_db_for_direct_thread_lookup(config: &Config) -> Option) -> JSONRPCErrorError { + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: message.into(), + data: None, + } +} + +fn internal_error(message: impl Into) -> JSONRPCErrorError { + JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: message.into(), + data: None, + } +} + +fn parse_thread_id_for_request(thread_id: &str) -> Result { + ThreadId::from_string(thread_id) + .map_err(|err| invalid_request(format!("invalid thread id: {err}"))) +} + fn non_empty_title(metadata: &ThreadMetadata) -> Option { let title = metadata.title.trim(); (!title.is_empty()).then(|| title.to_string()) diff --git a/codex-rs/app-server/src/codex_message_processor/thread_goal_handlers.rs b/codex-rs/app-server/src/codex_message_processor/thread_goal_handlers.rs new file mode 100644 index 0000000000..f837ef9dc3 --- /dev/null +++ b/codex-rs/app-server/src/codex_message_processor/thread_goal_handlers.rs @@ -0,0 +1,466 @@ +use super::*; + +impl CodexMessageProcessor { + pub(super) async fn thread_goal_set( + &self, + request_id: ConnectionRequestId, + params: ThreadGoalSetParams, + ) { + if !self.config.features.enabled(Feature::Goals) { + self.send_invalid_request_error(request_id, "goals feature is disabled".to_string()) + .await; + return; + } + + let thread_id = match parse_thread_id_for_request(params.thread_id.as_str()) { + Ok(thread_id) => thread_id, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let state_db = match self.state_db_for_materialized_thread(thread_id).await { + Ok(state_db) => state_db, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let running_thread = self.thread_manager.get_thread(thread_id).await.ok(); + let rollout_path = match running_thread.as_ref() { + Some(thread) => match thread.rollout_path() { + Some(path) => path, + None => { + self.send_invalid_request_error( + request_id, + format!("ephemeral thread does not support goals: {thread_id}"), + ) + .await; + return; + } + }, + None => { + match find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()) + .await + { + Ok(Some(path)) => path, + Ok(None) => { + self.send_invalid_request_error( + request_id, + format!("thread not found: {thread_id}"), + ) + .await; + return; + } + Err(err) => { + self.send_internal_error( + request_id, + format!("failed to locate thread id {thread_id}: {err}"), + ) + .await; + return; + } + } + } + }; + reconcile_rollout( + Some(&state_db), + rollout_path.as_path(), + self.config.model_provider_id.as_str(), + /*builder*/ None, + &[], + /*archived_only*/ None, + /*new_thread_memory_mode*/ None, + ) + .await; + + let listener_command_tx = { + let thread_state = self.thread_state_manager.thread_state(thread_id).await; + let thread_state = thread_state.lock().await; + thread_state.listener_command_tx() + }; + let status = params.status.map(thread_goal_status_to_state); + let objective = params.objective.as_deref().map(str::trim); + + if let Some(objective) = objective { + if objective.is_empty() { + self.send_invalid_request_error( + request_id, + "goal objective must not be empty".to_string(), + ) + .await; + return; + } + if let Err(message) = validate_goal_budget(params.token_budget.flatten()) { + self.send_invalid_request_error(request_id, message).await; + return; + } + } else if let Some(token_budget) = params.token_budget + && let Err(message) = validate_goal_budget(token_budget) + { + self.send_invalid_request_error(request_id, message).await; + return; + } + + let goal = if let Some(objective) = objective { + match state_db.get_thread_goal(thread_id).await { + Ok(goal) => { + if let Some(goal) = goal.as_ref().filter(|goal| { + goal.objective == objective + && goal.status != codex_state::ThreadGoalStatus::Complete + }) { + state_db + .update_thread_goal( + thread_id, + codex_state::ThreadGoalUpdate { + status, + token_budget: params.token_budget, + expected_goal_id: Some(goal.goal_id.clone()), + }, + ) + .await + .and_then(|goal| { + goal.ok_or_else(|| { + anyhow::anyhow!( + "cannot update goal for thread {thread_id}: no goal exists" + ) + }) + }) + } else { + state_db + .replace_thread_goal( + thread_id, + objective, + status.unwrap_or(codex_state::ThreadGoalStatus::Active), + params.token_budget.flatten(), + ) + .await + } + } + Err(err) => Err(err), + } + } else { + state_db + .update_thread_goal( + thread_id, + codex_state::ThreadGoalUpdate { + status, + token_budget: params.token_budget, + expected_goal_id: None, + }, + ) + .await + .and_then(|goal| { + goal.ok_or_else(|| { + anyhow::anyhow!("cannot update goal for thread {thread_id}: no goal exists") + }) + }) + }; + + let goal = match goal { + Ok(goal) => goal, + Err(err) => { + self.send_invalid_request_error(request_id, err.to_string()) + .await; + return; + } + }; + let goal = api_thread_goal_from_state(goal); + self.outgoing + .send_response( + request_id.clone(), + ThreadGoalSetResponse { goal: goal.clone() }, + ) + .await; + self.emit_thread_goal_updated_ordered(thread_id, goal, listener_command_tx) + .await; + } + + pub(super) async fn thread_goal_get( + &self, + request_id: ConnectionRequestId, + params: ThreadGoalGetParams, + ) { + if !self.config.features.enabled(Feature::Goals) { + self.send_invalid_request_error(request_id, "goals feature is disabled".to_string()) + .await; + return; + } + + let thread_id = match parse_thread_id_for_request(params.thread_id.as_str()) { + Ok(thread_id) => thread_id, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let state_db = match self.state_db_for_materialized_thread(thread_id).await { + Ok(state_db) => state_db, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let goal = match state_db.get_thread_goal(thread_id).await { + Ok(goal) => goal.map(api_thread_goal_from_state), + Err(err) => { + self.send_internal_error(request_id, format!("failed to read thread goal: {err}")) + .await; + return; + } + }; + self.outgoing + .send_response(request_id, ThreadGoalGetResponse { goal }) + .await; + } + + pub(super) async fn thread_goal_clear( + &self, + request_id: ConnectionRequestId, + params: ThreadGoalClearParams, + ) { + if !self.config.features.enabled(Feature::Goals) { + self.send_invalid_request_error(request_id, "goals feature is disabled".to_string()) + .await; + return; + } + + let thread_id = match parse_thread_id_for_request(params.thread_id.as_str()) { + Ok(thread_id) => thread_id, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let state_db = match self.state_db_for_materialized_thread(thread_id).await { + Ok(state_db) => state_db, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let running_thread = self.thread_manager.get_thread(thread_id).await.ok(); + let rollout_path = match running_thread.as_ref() { + Some(thread) => match thread.rollout_path() { + Some(path) => path, + None => { + self.send_invalid_request_error( + request_id, + format!("ephemeral thread does not support goals: {thread_id}"), + ) + .await; + return; + } + }, + None => { + match find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()) + .await + { + Ok(Some(path)) => path, + Ok(None) => { + self.send_invalid_request_error( + request_id, + format!("thread not found: {thread_id}"), + ) + .await; + return; + } + Err(err) => { + self.send_internal_error( + request_id, + format!("failed to locate thread id {thread_id}: {err}"), + ) + .await; + return; + } + } + } + }; + reconcile_rollout( + Some(&state_db), + rollout_path.as_path(), + self.config.model_provider_id.as_str(), + /*builder*/ None, + &[], + /*archived_only*/ None, + /*new_thread_memory_mode*/ None, + ) + .await; + + let listener_command_tx = { + let thread_state = self.thread_state_manager.thread_state(thread_id).await; + let thread_state = thread_state.lock().await; + thread_state.listener_command_tx() + }; + let cleared = match state_db.delete_thread_goal(thread_id).await { + Ok(cleared) => cleared, + Err(err) => { + self.send_internal_error(request_id, format!("failed to clear thread goal: {err}")) + .await; + return; + } + }; + + self.outgoing + .send_response(request_id, ThreadGoalClearResponse { cleared }) + .await; + if cleared { + self.emit_thread_goal_cleared_ordered(thread_id, listener_command_tx) + .await; + } + } + + async fn state_db_for_materialized_thread( + &self, + thread_id: ThreadId, + ) -> Result { + if let Ok(thread) = self.thread_manager.get_thread(thread_id).await { + if thread.rollout_path().is_none() { + return Err(invalid_request(format!( + "ephemeral thread does not support goals: {thread_id}" + ))); + } + if let Some(state_db) = thread.state_db() { + return Ok(state_db); + } + } else { + match find_thread_path_by_id_str(&self.config.codex_home, &thread_id.to_string()).await + { + Ok(Some(_)) => {} + Ok(None) => { + return Err(invalid_request(format!("thread not found: {thread_id}"))); + } + Err(err) => { + return Err(internal_error(format!( + "failed to locate thread id {thread_id}: {err}" + ))); + } + } + } + + open_state_db_for_direct_thread_lookup(&self.config) + .await + .ok_or_else(|| internal_error("sqlite state db unavailable for thread goals")) + } + + pub(super) async fn emit_thread_goal_snapshot(&self, thread_id: ThreadId) { + let state_db = match self.state_db_for_materialized_thread(thread_id).await { + Ok(state_db) => state_db, + Err(err) => { + warn!( + "failed to open state db before emitting thread goal resume snapshot for {thread_id}: {}", + err.message + ); + return; + } + }; + let listener_command_tx = { + let thread_state = self.thread_state_manager.thread_state(thread_id).await; + let thread_state = thread_state.lock().await; + thread_state.listener_command_tx() + }; + if let Some(listener_command_tx) = listener_command_tx { + let command = crate::thread_state::ThreadListenerCommand::EmitThreadGoalSnapshot { + state_db: state_db.clone(), + }; + if listener_command_tx.send(command).is_ok() { + return; + } + warn!( + "failed to enqueue thread goal snapshot for {thread_id}: listener command channel is closed" + ); + } + send_thread_goal_snapshot_notification(&self.outgoing, thread_id, &state_db).await; + } + + async fn emit_thread_goal_updated_ordered( + &self, + thread_id: ThreadId, + goal: ThreadGoal, + listener_command_tx: Option>, + ) { + if let Some(listener_command_tx) = listener_command_tx { + let command = crate::thread_state::ThreadListenerCommand::EmitThreadGoalUpdated { + goal: goal.clone(), + }; + if listener_command_tx.send(command).is_ok() { + return; + } + warn!( + "failed to enqueue thread goal update for {thread_id}: listener command channel is closed" + ); + } + self.outgoing + .send_server_notification(ServerNotification::ThreadGoalUpdated( + ThreadGoalUpdatedNotification { + thread_id: thread_id.to_string(), + turn_id: None, + goal, + }, + )) + .await; + } + + async fn emit_thread_goal_cleared_ordered( + &self, + thread_id: ThreadId, + listener_command_tx: Option>, + ) { + if let Some(listener_command_tx) = listener_command_tx { + let command = crate::thread_state::ThreadListenerCommand::EmitThreadGoalCleared; + if listener_command_tx.send(command).is_ok() { + return; + } + warn!( + "failed to enqueue thread goal clear for {thread_id}: listener command channel is closed" + ); + } + self.outgoing + .send_server_notification(ServerNotification::ThreadGoalCleared( + ThreadGoalClearedNotification { + thread_id: thread_id.to_string(), + }, + )) + .await; + } +} + +fn validate_goal_budget(value: Option) -> Result<(), String> { + if let Some(value) = value + && value <= 0 + { + return Err("goal budgets must be positive when provided".to_string()); + } + Ok(()) +} + +fn thread_goal_status_to_state(status: ThreadGoalStatus) -> codex_state::ThreadGoalStatus { + match status { + ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active, + ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused, + ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited, + ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete, + } +} + +fn thread_goal_status_from_state(status: codex_state::ThreadGoalStatus) -> ThreadGoalStatus { + match status { + codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active, + codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused, + codex_state::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited, + codex_state::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete, + } +} + +pub(super) fn api_thread_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal { + ThreadGoal { + thread_id: goal.thread_id.to_string(), + objective: goal.objective, + status: thread_goal_status_from_state(goal.status), + token_budget: goal.token_budget, + tokens_used: goal.tokens_used, + time_used_seconds: goal.time_used_seconds, + created_at: goal.created_at.timestamp(), + updated_at: goal.updated_at.timestamp(), + } +} diff --git a/codex-rs/app-server/src/thread_state.rs b/codex-rs/app-server/src/thread_state.rs index d4347933ef..73d1c5961b 100644 --- a/codex-rs/app-server/src/thread_state.rs +++ b/codex-rs/app-server/src/thread_state.rs @@ -1,6 +1,7 @@ use crate::outgoing_message::ConnectionId; use crate::outgoing_message::ConnectionRequestId; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadGoal; use codex_app_server_protocol::ThreadHistoryBuilder; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError; @@ -9,6 +10,7 @@ use codex_core::ThreadConfigSnapshot; use codex_protocol::ThreadId; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::RolloutItem; +use codex_rollout::state_db::StateDbHandle; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; use std::collections::HashSet; @@ -31,6 +33,8 @@ pub(crate) struct PendingThreadResumeRequest { pub(crate) config_snapshot: ThreadConfigSnapshot, pub(crate) instruction_sources: Vec, pub(crate) thread_summary: codex_app_server_protocol::Thread, + pub(crate) emit_thread_goal_update: bool, + pub(crate) thread_goal_state_db: Option, pub(crate) include_turns: bool, } @@ -38,6 +42,16 @@ pub(crate) struct PendingThreadResumeRequest { pub(crate) enum ThreadListenerCommand { // SendThreadResumeResponse is used to resume an already running thread by sending the thread's history to the client and atomically subscribing for new updates. SendThreadResumeResponse(Box), + // EmitThreadGoalUpdated is used to order app-server goal updates with running-thread resume responses. + EmitThreadGoalUpdated { + goal: ThreadGoal, + }, + // EmitThreadGoalCleared is used to order app-server goal clears with running-thread resume responses. + EmitThreadGoalCleared, + // EmitThreadGoalSnapshot is used to read and emit the latest goal state in the listener order. + EmitThreadGoalSnapshot { + state_db: StateDbHandle, + }, // ResolveServerRequest is used to notify the client that the request has been resolved. // It is executed in the thread listener's context to ensure that the resolved notification is ordered with regard to the request itself. ResolveServerRequest { diff --git a/codex-rs/app-server/src/transport/mod.rs b/codex-rs/app-server/src/transport/mod.rs index 22e7a80a5d..b610f099ae 100644 --- a/codex-rs/app-server/src/transport/mod.rs +++ b/codex-rs/app-server/src/transport/mod.rs @@ -7,6 +7,7 @@ use crate::outgoing_message::OutgoingEnvelope; use crate::outgoing_message::OutgoingError; use crate::outgoing_message::OutgoingMessage; use crate::outgoing_message::QueuedOutgoingMessage; +use codex_app_server_protocol::ExperimentalApi; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::ServerRequest; @@ -337,6 +338,13 @@ fn should_skip_notification_for_connection( }; match message { OutgoingMessage::AppServerNotification(notification) => { + if notification.experimental_reason().is_some() + && !connection_state + .experimental_api_enabled + .load(Ordering::Acquire) + { + return true; + } let method = notification.to_string(); opted_out_notification_methods.contains(method.as_str()) } @@ -469,6 +477,9 @@ mod tests { use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; + use codex_app_server_protocol::ThreadGoal; + use codex_app_server_protocol::ThreadGoalStatus; + use codex_app_server_protocol::ThreadGoalUpdatedNotification; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::json; @@ -479,6 +490,23 @@ mod tests { AbsolutePathBuf::from_absolute_path(path).expect("absolute path") } + fn thread_goal_updated_notification() -> ServerNotification { + ServerNotification::ThreadGoalUpdated(ThreadGoalUpdatedNotification { + thread_id: "thread-1".to_string(), + turn_id: None, + goal: ThreadGoal { + thread_id: "thread-1".to_string(), + objective: "ship goal mode".to_string(), + status: ThreadGoalStatus::Active, + token_budget: None, + tokens_used: 0, + time_used_seconds: 0, + created_at: 1, + updated_at: 1, + }, + }) + } + #[test] fn listen_off_parses_as_off_transport() { assert_eq!( @@ -810,6 +838,76 @@ mod tests { )); } + #[tokio::test] + async fn experimental_notifications_are_dropped_without_capability() { + let connection_id = ConnectionId(12); + let (writer_tx, mut writer_rx) = mpsc::channel(1); + + let mut connections = HashMap::new(); + connections.insert( + connection_id, + OutboundConnectionState::new( + writer_tx, + Arc::new(AtomicBool::new(true)), + Arc::new(AtomicBool::new(false)), + Arc::new(RwLock::new(HashSet::new())), + /*disconnect_sender*/ None, + ), + ); + + route_outgoing_envelope( + &mut connections, + OutgoingEnvelope::ToConnection { + connection_id, + message: OutgoingMessage::AppServerNotification(thread_goal_updated_notification()), + write_complete_tx: None, + }, + ) + .await; + + assert!( + writer_rx.try_recv().is_err(), + "experimental notifications should not reach clients without capability" + ); + } + + #[tokio::test] + async fn experimental_notifications_are_preserved_with_capability() { + let connection_id = ConnectionId(13); + let (writer_tx, mut writer_rx) = mpsc::channel(1); + + let mut connections = HashMap::new(); + connections.insert( + connection_id, + OutboundConnectionState::new( + writer_tx, + Arc::new(AtomicBool::new(true)), + Arc::new(AtomicBool::new(true)), + Arc::new(RwLock::new(HashSet::new())), + /*disconnect_sender*/ None, + ), + ); + + route_outgoing_envelope( + &mut connections, + OutgoingEnvelope::ToConnection { + connection_id, + message: OutgoingMessage::AppServerNotification(thread_goal_updated_notification()), + write_complete_tx: None, + }, + ) + .await; + + let message = writer_rx + .recv() + .await + .expect("experimental notification should reach opted-in client"); + assert!(matches!( + message.message, + OutgoingMessage::AppServerNotification(ServerNotification::ThreadGoalUpdated(_)) + )); + } + #[tokio::test] async fn command_execution_request_approval_strips_additional_permissions_without_capability() { let connection_id = ConnectionId(8); diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index e450dd50df..f3d3923759 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -28,6 +28,9 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SessionSource; +use codex_app_server_protocol::ThreadGoalClearResponse; +use codex_app_server_protocol::ThreadGoalSetResponse; +use codex_app_server_protocol::ThreadGoalStatus; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadMetadataGitInfoUpdateParams; use codex_app_server_protocol::ThreadMetadataUpdateParams; @@ -168,6 +171,63 @@ async fn thread_resume_rejects_unmaterialized_thread() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_goal_get_rejects_unmaterialized_thread() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config.replace( + "general_analytics = true\n", + "general_analytics = true\ngoals = true\n", + ), + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.2-codex".to_string()), + ephemeral: Some(true), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let goal_id = mcp + .send_raw_request( + "thread/goal/get", + Some(json!({ + "threadId": thread.id, + })), + ) + .await?; + let goal_err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(goal_id)), + ) + .await??; + assert!( + goal_err + .error + .message + .contains("ephemeral thread does not support goals"), + "unexpected goal/get error: {}", + goal_err.error.message + ); + + Ok(()) +} + #[tokio::test] async fn thread_resume_tracks_thread_initialized_analytics() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -326,6 +386,337 @@ async fn thread_resume_can_skip_turns_for_metadata_only_resume() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_resume_emits_paused_goal_update() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config.replace( + "general_analytics = true\n", + "general_analytics = true\ngoals = true\n", + ), + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.2-codex".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "materialize this thread".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let _turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let goal_id = mcp + .send_raw_request( + "thread/goal/set", + Some(json!({ + "threadId": thread.id, + "objective": "keep polishing", + "status": "paused", + })), + ) + .await?; + let goal_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(goal_id)), + ) + .await??; + let _goal: ThreadGoalSetResponse = to_response(goal_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/updated"), + ) + .await??; + + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let _resume: ThreadResumeResponse = to_response(resume_resp)?; + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/updated"), + ) + .await??; + let notification: ServerNotification = notification.try_into()?; + let ServerNotification::ThreadGoalUpdated(notification) = notification else { + anyhow::bail!("expected thread goal update notification"); + }; + assert_eq!(notification.goal.status, ThreadGoalStatus::Paused); + + Ok(()) +} + +#[tokio::test] +async fn thread_goal_set_preserves_budget_limited_same_objective() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config.replace( + "general_analytics = true\n", + "general_analytics = true\ngoals = true\n", + ), + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.2-codex".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "materialize this thread".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let _turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let goal_id = mcp + .send_raw_request( + "thread/goal/set", + Some(json!({ + "threadId": thread.id, + "objective": "keep polishing", + "status": "budgetLimited", + "tokenBudget": 10, + })), + ) + .await?; + let goal_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(goal_id)), + ) + .await??; + let goal: ThreadGoalSetResponse = to_response(goal_resp)?; + assert_eq!(goal.goal.status, ThreadGoalStatus::BudgetLimited); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/updated"), + ) + .await??; + + let replacement_id = mcp + .send_raw_request( + "thread/goal/set", + Some(json!({ + "threadId": thread.id, + "objective": "keep polishing", + })), + ) + .await?; + let replacement_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(replacement_id)), + ) + .await??; + let replacement: ThreadGoalSetResponse = to_response(replacement_resp)?; + + assert_eq!(replacement.goal.status, ThreadGoalStatus::BudgetLimited); + assert_eq!(replacement.goal.token_budget, Some(10)); + assert_eq!(replacement.goal.tokens_used, 0); + assert_eq!(replacement.goal.time_used_seconds, 0); + + Ok(()) +} + +#[tokio::test] +async fn thread_goal_clear_deletes_goal_and_notifies() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let config_path = codex_home.path().join("config.toml"); + let config = std::fs::read_to_string(&config_path)?; + std::fs::write( + &config_path, + config.replace( + "general_analytics = true\n", + "general_analytics = true\ngoals = true\n", + ), + )?; + + let mut mcp = McpProcess::new_without_managed_config(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.2-codex".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "materialize this thread".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let _turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let goal_id = mcp + .send_raw_request( + "thread/goal/set", + Some(json!({ + "threadId": thread.id, + "objective": "keep polishing", + })), + ) + .await?; + let goal_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(goal_id)), + ) + .await??; + let _goal: ThreadGoalSetResponse = to_response(goal_resp)?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/updated"), + ) + .await??; + + let clear_id = mcp + .send_raw_request( + "thread/goal/clear", + Some(json!({ + "threadId": thread.id, + })), + ) + .await?; + let clear_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(clear_id)), + ) + .await??; + let clear: ThreadGoalClearResponse = to_response(clear_resp)?; + assert!(clear.cleared); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/goal/cleared"), + ) + .await??; + + let get_id = mcp + .send_raw_request( + "thread/goal/get", + Some(json!({ + "threadId": thread.id, + })), + ) + .await?; + let get_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(get_id)), + ) + .await??; + let get: codex_app_server_protocol::ThreadGoalGetResponse = to_response(get_resp)?; + assert_eq!(None, get.goal); + + let clear_again_id = mcp + .send_raw_request( + "thread/goal/clear", + Some(json!({ + "threadId": thread.id, + })), + ) + .await?; + let clear_again_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(clear_again_id)), + ) + .await??; + let clear_again: ThreadGoalClearResponse = to_response(clear_again_resp)?; + assert!(!clear_again.cleared); + + Ok(()) +} + #[tokio::test] async fn thread_resume_by_path_uses_remote_thread_store_error() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; diff --git a/codex-rs/core/src/agent/status.rs b/codex-rs/core/src/agent/status.rs index c343e19503..43be718865 100644 --- a/codex-rs/core/src/agent/status.rs +++ b/codex-rs/core/src/agent/status.rs @@ -8,7 +8,8 @@ pub(crate) fn agent_status_from_event(msg: &EventMsg) -> Option { EventMsg::TurnStarted(_) => Some(AgentStatus::Running), EventMsg::TurnComplete(ev) => Some(AgentStatus::Completed(ev.last_agent_message.clone())), EventMsg::TurnAborted(ev) => match ev.reason { - codex_protocol::protocol::TurnAbortReason::Interrupted => { + codex_protocol::protocol::TurnAbortReason::Interrupted + | codex_protocol::protocol::TurnAbortReason::BudgetLimited => { Some(AgentStatus::Interrupted) } _ => Some(AgentStatus::Errored(format!("{:?}", ev.reason))), diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index db5df955d5..fe9320b12e 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1502,6 +1502,7 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::AgentReasoningSectionBreak(_) | EventMsg::SessionConfigured(_) | EventMsg::ThreadNameUpdated(_) + | EventMsg::ThreadGoalUpdated(_) | EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_) | EventMsg::McpToolCallBegin(_) diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 99fffb8013..f759f8bb85 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -321,6 +321,9 @@ async fn run_codex_tool_session_inner( EventMsg::ThreadNameUpdated(_) => { // Ignore session metadata updates in MCP tool runner. } + EventMsg::ThreadGoalUpdated(_) => { + // Ignore thread goal metadata updates in MCP tool runner. + } EventMsg::AgentMessageDelta(_) => { // TODO: think how we want to support this in the MCP } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 63e8ab0e50..94edb0cb09 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1464,6 +1464,9 @@ pub enum EventMsg { /// Updated session metadata (e.g., thread name changes). ThreadNameUpdated(ThreadNameUpdatedEvent), + /// Updated long-running goal metadata for the thread. + ThreadGoalUpdated(ThreadGoalUpdatedEvent), + /// Incremental MCP startup progress updates. McpStartupUpdate(McpStartupUpdateEvent), @@ -3612,6 +3615,43 @@ pub struct ThreadNameUpdatedEvent { pub thread_name: Option, } +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "protocol/")] +pub enum ThreadGoalStatus { + Active, + Paused, + BudgetLimited, + Complete, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "protocol/")] +pub struct ThreadGoal { + pub thread_id: ThreadId, + pub objective: String, + pub status: ThreadGoalStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub token_budget: Option, + pub tokens_used: i64, + pub time_used_seconds: i64, + pub created_at: i64, + pub updated_at: i64, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "protocol/")] +pub struct ThreadGoalUpdatedEvent { + pub thread_id: ThreadId, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub turn_id: Option, + pub goal: ThreadGoal, +} + /// User's decision in response to an ExecApprovalRequest. #[derive(Debug, Default, Clone, Deserialize, Serialize, PartialEq, Eq, Display, JsonSchema, TS)] #[serde(rename_all = "snake_case")] @@ -3714,6 +3754,7 @@ pub enum TurnAbortReason { Interrupted, Replaced, ReviewEnded, + BudgetLimited, } #[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index b3267a23ea..2aa5af5af6 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -226,6 +226,7 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Option<&'static s | EventMsg::AgentReasoningRawContent(_) | EventMsg::AgentReasoningRawContentDelta(_) | EventMsg::AgentReasoningSectionBreak(_) + | EventMsg::ThreadGoalUpdated(_) | EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_) | EventMsg::McpToolCallBegin(_) @@ -403,8 +405,9 @@ impl TraceExecutionStatus for PatchApplyStatus { fn execution_status_for_abort_reason(reason: &TurnAbortReason) -> ExecutionStatus { match reason { - TurnAbortReason::Interrupted | TurnAbortReason::Replaced | TurnAbortReason::ReviewEnded => { - ExecutionStatus::Cancelled - } + TurnAbortReason::Interrupted + | TurnAbortReason::Replaced + | TurnAbortReason::ReviewEnded + | TurnAbortReason::BudgetLimited => ExecutionStatus::Cancelled, } } diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index ddd42e5778..8459f96c13 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -145,6 +145,7 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::AgentReasoningSectionBreak(_) | EventMsg::RawResponseItem(_) | EventMsg::SessionConfigured(_) + | EventMsg::ThreadGoalUpdated(_) | EventMsg::McpToolCallBegin(_) | EventMsg::WebSearchBegin(_) | EventMsg::ExecCommandBegin(_) diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index 2cfa3c9dda..0a90c2c9b0 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -350,6 +350,12 @@ fn server_notification_thread_target( ServerNotification::ThreadTokenUsageUpdated(notification) => { Some(notification.thread_id.as_str()) } + ServerNotification::ThreadGoalUpdated(notification) => { + Some(notification.thread_id.as_str()) + } + ServerNotification::ThreadGoalCleared(notification) => { + Some(notification.thread_id.as_str()) + } ServerNotification::TurnStarted(notification) => Some(notification.thread_id.as_str()), ServerNotification::HookStarted(notification) => Some(notification.thread_id.as_str()), ServerNotification::TurnCompleted(notification) => Some(notification.thread_id.as_str()), diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b748b11e6a..4aa49d7119 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -6533,6 +6533,8 @@ impl ChatWidget { notification.token_usage, ))); } + ServerNotification::ThreadGoalUpdated(_) => {} + ServerNotification::ThreadGoalCleared(_) => {} ServerNotification::ThreadNameUpdated(notification) => { match ThreadId::from_string(¬ification.thread_id) { Ok(thread_id) => self.on_thread_name_updated( @@ -7089,6 +7091,7 @@ impl ChatWidget { match msg { EventMsg::SessionConfigured(e) => self.on_session_configured(e), EventMsg::ThreadNameUpdated(e) => self.on_thread_name_updated(e), + EventMsg::ThreadGoalUpdated(_) => {} // NOTE: All three AgentMessage arms feed `record_agent_markdown` even // when the message is otherwise not rendered (thread-snapshot replay, // non-review live messages). This ensures the copy source stays @@ -7200,6 +7203,9 @@ impl ChatWidget { TurnAbortReason::ReviewEnded => { self.on_interrupted_turn(ev.reason); } + TurnAbortReason::BudgetLimited => { + self.on_interrupted_turn(ev.reason); + } }, EventMsg::PlanUpdate(update) => self.on_plan_update(update), EventMsg::ExecApprovalRequest(ev) => { From 32ace07ac57ef0c7774cbc6fe55ac089fa307868 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 24 Apr 2026 20:54:40 -0700 Subject: [PATCH 15/67] Add goal model tools (3 / 5) (#18075) Adds the model-facing goal tools on top of the app-server API from PR 2. ## Why Once goals are persisted and exposed to clients, the model needs a small, constrained tool surface for goal workflows. The tool contract should let the model inspect goals, create them only when explicitly requested, and mark them complete without giving it broad control over user/runtime-owned state. ## What changed - Added `get_goal`, `create_goal`, and `update_goal` tool specs behind the `goals` feature flag. - Added core goal tool handlers that validate objectives and token budgets before mutating persisted state. - Constrained `create_goal` to create only when no goal exists, with optional `token_budget` only when a budget is explicitly provided. - Tightened the `create_goal` instructions so the model does not infer goals from ordinary task requests. - Constrained `update_goal` to expose only goal completion; pause, resume, clear, and budget-limited transitions remain user- or runtime-controlled. - Registered the goal tools in the tool registry and kept them out of review contexts where they should not appear. ## Verification - Added tool-registry coverage for feature gating and tool availability. - Added core session tests for create/get/update behavior, duplicate goal rejection, budget validation, and completion-only updates. --- codex-rs/core/src/goals.rs | 259 ++++++++++++++++ codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/session/review.rs | 2 + codex-rs/core/src/session/tests.rs | 220 ++++++++++++++ codex-rs/core/src/session/turn_context.rs | 5 + codex-rs/core/src/tools/handlers/goal.rs | 276 ++++++++++++++++++ codex-rs/core/src/tools/handlers/mod.rs | 2 + codex-rs/core/src/tools/spec.rs | 5 + codex-rs/tools/src/goal_tool.rs | 112 +++++++ codex-rs/tools/src/lib.rs | 7 + codex-rs/tools/src/tool_config.rs | 8 + codex-rs/tools/src/tool_registry_plan.rs | 23 ++ .../tools/src/tool_registry_plan_tests.rs | 54 ++++ .../tools/src/tool_registry_plan_types.rs | 1 + 14 files changed, 975 insertions(+) create mode 100644 codex-rs/core/src/goals.rs create mode 100644 codex-rs/core/src/tools/handlers/goal.rs create mode 100644 codex-rs/tools/src/goal_tool.rs diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs new file mode 100644 index 0000000000..18fa8da0ae --- /dev/null +++ b/codex-rs/core/src/goals.rs @@ -0,0 +1,259 @@ +//! Core support for persisted thread goals. +//! +//! This module bridges core sessions and the state-db goal table. It validates +//! goal mutations, converts between state and protocol shapes, emits goal-update +//! events, and owns helper hooks used by goal lifecycle behavior. + +use crate::StateDbHandle; +use crate::session::session::Session; +use crate::session::turn_context::TurnContext; +use anyhow::Context; +use codex_features::Feature; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ThreadGoal; +use codex_protocol::protocol::ThreadGoalStatus; +use codex_protocol::protocol::ThreadGoalUpdatedEvent; +use codex_rollout::state_db::reconcile_rollout; +use codex_thread_store::LocalThreadStore; + +pub(crate) struct SetGoalRequest { + pub(crate) objective: Option, + pub(crate) status: Option, + pub(crate) token_budget: Option>, +} + +pub(crate) struct CreateGoalRequest { + pub(crate) objective: String, + pub(crate) token_budget: Option, +} + +impl Session { + pub(crate) async fn get_thread_goal(&self) -> anyhow::Result> { + if !self.enabled(Feature::Goals) { + anyhow::bail!("goals feature is disabled"); + } + + let state_db = self.state_db_for_thread_goals().await?; + state_db + .get_thread_goal(self.conversation_id) + .await + .map(|goal| goal.map(protocol_goal_from_state)) + } + + pub(crate) async fn set_thread_goal( + &self, + turn_context: &TurnContext, + request: SetGoalRequest, + ) -> anyhow::Result { + if !self.enabled(Feature::Goals) { + anyhow::bail!("goals feature is disabled"); + } + + validate_goal_budget(request.token_budget.flatten())?; + let state_db = self.state_db_for_thread_goals().await?; + let goal = if let Some(objective) = request.objective { + let objective = objective.trim(); + if objective.is_empty() { + anyhow::bail!("goal objective must not be empty"); + } + state_db + .replace_thread_goal( + self.conversation_id, + objective, + request + .status + .map(state_goal_status_from_protocol) + .unwrap_or(codex_state::ThreadGoalStatus::Active), + request.token_budget.flatten(), + ) + .await? + } else { + let status = request.status.map(state_goal_status_from_protocol); + state_db + .update_thread_goal( + self.conversation_id, + codex_state::ThreadGoalUpdate { + status, + token_budget: request.token_budget, + expected_goal_id: None, + }, + ) + .await? + .ok_or_else(|| { + anyhow::anyhow!( + "cannot update goal for thread {}: no goal exists", + self.conversation_id + ) + })? + }; + + let goal = protocol_goal_from_state(goal); + self.send_event( + turn_context, + EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { + thread_id: self.conversation_id, + turn_id: Some(turn_context.sub_id.clone()), + goal: goal.clone(), + }), + ) + .await; + Ok(goal) + } + + pub(crate) async fn create_thread_goal( + &self, + turn_context: &TurnContext, + request: CreateGoalRequest, + ) -> anyhow::Result { + if !self.enabled(Feature::Goals) { + anyhow::bail!("goals feature is disabled"); + } + + let CreateGoalRequest { + objective, + token_budget, + } = request; + validate_goal_budget(token_budget)?; + let objective = objective.trim(); + if objective.is_empty() { + anyhow::bail!("goal objective must not be empty"); + } + + let state_db = self.state_db_for_thread_goals().await?; + let goal = state_db + .insert_thread_goal( + self.conversation_id, + objective, + codex_state::ThreadGoalStatus::Active, + token_budget, + ) + .await? + .ok_or_else(|| { + anyhow::anyhow!( + "cannot create a new goal because thread {} already has a goal", + self.conversation_id + ) + })?; + + let goal = protocol_goal_from_state(goal); + self.send_event( + turn_context, + EventMsg::ThreadGoalUpdated(ThreadGoalUpdatedEvent { + thread_id: self.conversation_id, + turn_id: Some(turn_context.sub_id.clone()), + goal: goal.clone(), + }), + ) + .await; + Ok(goal) + } +} + +impl Session { + async fn state_db_for_thread_goals(&self) -> anyhow::Result { + let config = self.get_config().await; + if config.ephemeral { + anyhow::bail!("thread goals require a persisted thread; this thread is ephemeral"); + } + + self.try_ensure_rollout_materialized() + .await + .context("failed to materialize rollout before opening state db for thread goals")?; + + let state_db = if let Some(state_db) = self.state_db() { + state_db + } else if let Some(local_store) = self + .services + .thread_store + .as_any() + .downcast_ref::() + { + local_store.state_db().await.ok_or_else(|| { + anyhow::anyhow!( + "thread goals require a local persisted thread with a state database" + ) + })? + } else { + anyhow::bail!("thread goals require a local persisted thread with a state database"); + }; + + let thread_metadata_present = state_db + .get_thread(self.conversation_id) + .await + .context("failed to read thread metadata before reconciling thread goals")? + .is_some(); + if !thread_metadata_present { + let rollout_path = self + .current_rollout_path() + .await + .context("failed to locate rollout before reconciling thread goals")? + .ok_or_else(|| { + anyhow::anyhow!("thread goals require materialized thread metadata") + })?; + reconcile_rollout( + Some(&state_db), + rollout_path.as_path(), + config.model_provider_id.as_str(), + /*builder*/ None, + &[], + /*archived_only*/ None, + /*new_thread_memory_mode*/ None, + ) + .await; + let thread_metadata_present = state_db + .get_thread(self.conversation_id) + .await + .context("failed to read thread metadata after reconciling thread goals")? + .is_some(); + if !thread_metadata_present { + anyhow::bail!("thread metadata is unavailable after reconciling thread goals"); + } + } + + Ok(state_db) + } +} + +pub(crate) fn protocol_goal_from_state(goal: codex_state::ThreadGoal) -> ThreadGoal { + ThreadGoal { + thread_id: goal.thread_id, + objective: goal.objective, + status: protocol_goal_status_from_state(goal.status), + token_budget: goal.token_budget, + tokens_used: goal.tokens_used, + time_used_seconds: goal.time_used_seconds, + created_at: goal.created_at.timestamp(), + updated_at: goal.updated_at.timestamp(), + } +} + +pub(crate) fn protocol_goal_status_from_state( + status: codex_state::ThreadGoalStatus, +) -> ThreadGoalStatus { + match status { + codex_state::ThreadGoalStatus::Active => ThreadGoalStatus::Active, + codex_state::ThreadGoalStatus::Paused => ThreadGoalStatus::Paused, + codex_state::ThreadGoalStatus::BudgetLimited => ThreadGoalStatus::BudgetLimited, + codex_state::ThreadGoalStatus::Complete => ThreadGoalStatus::Complete, + } +} + +pub(crate) fn state_goal_status_from_protocol( + status: ThreadGoalStatus, +) -> codex_state::ThreadGoalStatus { + match status { + ThreadGoalStatus::Active => codex_state::ThreadGoalStatus::Active, + ThreadGoalStatus::Paused => codex_state::ThreadGoalStatus::Paused, + ThreadGoalStatus::BudgetLimited => codex_state::ThreadGoalStatus::BudgetLimited, + ThreadGoalStatus::Complete => codex_state::ThreadGoalStatus::Complete, + } +} + +pub(crate) fn validate_goal_budget(value: Option) -> anyhow::Result<()> { + if let Some(value) = value + && value <= 0 + { + anyhow::bail!("goal budgets must be positive when provided"); + } + Ok(()) +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 54fadc6fd3..3e2d2ee523 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -37,6 +37,7 @@ pub mod file_watcher; mod flags; #[cfg(test)] mod git_info_tests; +mod goals; mod guardian; mod hook_runtime; mod installation_id; diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 9d502ab1d7..799af791eb 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -24,6 +24,7 @@ pub(super) async fn spawn_review_thread( let _ = review_features.disable(Feature::WebSearchRequest); let _ = review_features.disable(Feature::WebSearchCached); let review_web_search_mode = WebSearchMode::Disabled; + let goal_tools_supported = !config.ephemeral && parent_turn_context.tools_config.goal_tools; let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &review_model_info, available_models: &sess @@ -51,6 +52,7 @@ pub(super) async fn spawn_review_thread( .with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) + .with_goal_tools_allowed(goal_tools_supported) .with_max_concurrent_threads_per_session(config.agent_max_threads) .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( &config.agent_roles, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 677f6d7a49..7046243d5a 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -57,6 +57,7 @@ use crate::tasks::execute_user_shell_command; use crate::tools::ToolRouter; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; +use crate::tools::handlers::GoalHandler; use crate::tools::handlers::ShellHandler; use crate::tools::handlers::UnifiedExecHandler; use crate::tools::registry::ToolHandler; @@ -101,6 +102,7 @@ use codex_protocol::protocol::ResumedHistory; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SkillScope; use codex_protocol::protocol::Submission; +use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::ThreadRolledBackEvent; use codex_protocol::protocol::TokenCountEvent; use codex_protocol::protocol::TokenUsage; @@ -3348,6 +3350,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { session_configuration.cwd.clone(), "turn_id".to_string(), skills_outcome, + /*goal_tools_supported*/ true, ); let (mailbox, mailbox_rx) = crate::agent::Mailbox::new(); @@ -4703,6 +4706,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( session_configuration.cwd.clone(), "turn_id".to_string(), skills_outcome, + /*goal_tools_supported*/ true, )); let (mailbox, mailbox_rx) = crate::agent::Mailbox::new(); @@ -6852,6 +6856,222 @@ async fn sample_rollout( ) } +#[tokio::test] +async fn create_goal_tool_rejects_existing_goal() { + let (mut session, turn_context) = make_session_and_context().await; + let _ = session.features.enable(Feature::Goals); + let session = Arc::new(session); + upsert_goal_tool_test_thread(session.as_ref()).await; + let turn_context = Arc::new(turn_context); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let handler = GoalHandler; + + handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), + tracker: Arc::clone(&tracker), + call_id: "create-goal-1".to_string(), + tool_name: codex_tools::ToolName::plain("create_goal"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "objective": "Keep the watcher alive", + "token_budget": 123, + }) + .to_string(), + }, + }) + .await + .expect("initial create_goal should succeed"); + + let response = handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), + tracker, + call_id: "create-goal-2".to_string(), + tool_name: codex_tools::ToolName::plain("create_goal"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "objective": "Replace the watcher", + "token_budget": 456, + }) + .to_string(), + }, + }) + .await; + + let Err(FunctionCallError::RespondToModel(output)) = response else { + panic!("expected create_goal to reject an existing goal"); + }; + assert_eq!( + output, + "cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete" + ); + + let goal = session + .get_thread_goal() + .await + .expect("read thread goal") + .expect("goal should still exist"); + assert_eq!(goal.objective, "Keep the watcher alive"); + assert_eq!(goal.token_budget, Some(123)); +} + +#[tokio::test] +async fn update_goal_tool_rejects_pausing_goal() { + let (mut session, turn_context) = make_session_and_context().await; + let _ = session.features.enable(Feature::Goals); + let session = Arc::new(session); + upsert_goal_tool_test_thread(session.as_ref()).await; + let turn_context = Arc::new(turn_context); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let handler = GoalHandler; + + handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), + tracker: Arc::clone(&tracker), + call_id: "create-goal".to_string(), + tool_name: codex_tools::ToolName::plain("create_goal"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "objective": "Keep the watcher alive", + "token_budget": 123, + }) + .to_string(), + }, + }) + .await + .expect("initial create_goal should succeed"); + + let response = handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), + tracker, + call_id: "pause-goal".to_string(), + tool_name: codex_tools::ToolName::plain("update_goal"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "status": "paused", + }) + .to_string(), + }, + }) + .await; + + let Err(FunctionCallError::RespondToModel(output)) = response else { + panic!("expected update_goal to reject pausing a goal"); + }; + assert_eq!( + output, + "update_goal can only mark the existing goal complete; pause, resume, and budget-limited status changes are controlled by the user or system" + ); + + let goal = session + .get_thread_goal() + .await + .expect("read thread goal") + .expect("goal should still exist"); + assert_eq!(goal.status, ThreadGoalStatus::Active); +} + +#[tokio::test] +async fn update_goal_tool_marks_goal_complete() { + let (mut session, turn_context) = make_session_and_context().await; + let _ = session.features.enable(Feature::Goals); + let session = Arc::new(session); + upsert_goal_tool_test_thread(session.as_ref()).await; + let turn_context = Arc::new(turn_context); + let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); + let handler = GoalHandler; + + handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), + tracker: Arc::clone(&tracker), + call_id: "create-goal".to_string(), + tool_name: codex_tools::ToolName::plain("create_goal"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "objective": "Keep the watcher alive", + "token_budget": 123, + }) + .to_string(), + }, + }) + .await + .expect("initial create_goal should succeed"); + + handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + cancellation_token: CancellationToken::new(), + tracker, + call_id: "complete-goal".to_string(), + tool_name: codex_tools::ToolName::plain("update_goal"), + source: ToolCallSource::Direct, + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "status": "complete", + }) + .to_string(), + }, + }) + .await + .expect("update_goal should mark the goal complete"); + + let goal = session + .get_thread_goal() + .await + .expect("read thread goal") + .expect("goal should still exist"); + assert_eq!(goal.status, ThreadGoalStatus::Complete); +} + +async fn upsert_goal_tool_test_thread(session: &Session) { + let config = session.get_config().await; + let state_db = codex_state::StateRuntime::init( + config.sqlite_home.clone(), + config.model_provider_id.clone(), + ) + .await + .expect("state db should initialize"); + let mut builder = codex_state::ThreadMetadataBuilder::new( + session.conversation_id, + config + .codex_home + .join("goal-tool-test-rollout.jsonl") + .to_path_buf(), + chrono::Utc::now(), + SessionSource::Exec, + ); + builder.cwd = config.cwd.to_path_buf(); + builder.model_provider = Some(config.model_provider_id.clone()); + builder.cli_version = Some(env!("CARGO_PKG_VERSION").to_string()); + builder.sandbox_policy = config.permissions.sandbox_policy.get().clone(); + builder.approval_mode = config.permissions.approval_policy.value(); + let metadata = builder.build(config.model_provider_id.as_str()); + state_db + .upsert_thread(&metadata) + .await + .expect("thread metadata should be upserted"); +} + #[tokio::test] async fn rejects_escalated_permissions_when_policy_not_on_request() { use crate::exec::ExecParams; diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index d2e6b5a214..11292c81c4 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -180,6 +180,7 @@ impl TurnContext { .with_spawn_agent_usage_hint(config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(config.multi_agent_v2.hide_spawn_agent_metadata) + .with_goal_tools_allowed(self.tools_config.goal_tools) .with_max_concurrent_threads_per_session(config.agent_max_threads) .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( &config.agent_roles, @@ -405,6 +406,7 @@ impl Session { cwd: AbsolutePathBuf, sub_id: String, skills_outcome: Arc, + goal_tools_supported: bool, ) -> TurnContext { let reasoning_effort = session_configuration.collaboration_mode.reasoning_effort(); let reasoning_summary = session_configuration @@ -441,6 +443,7 @@ impl Session { .with_spawn_agent_usage_hint(per_turn_config.multi_agent_v2.usage_hint_enabled) .with_spawn_agent_usage_hint_text(per_turn_config.multi_agent_v2.usage_hint_text.clone()) .with_hide_spawn_agent_metadata(per_turn_config.multi_agent_v2.hide_spawn_agent_metadata) + .with_goal_tools_allowed(goal_tools_supported) .with_max_concurrent_threads_per_session(per_turn_config.agent_max_threads) .with_agent_type_description(crate::agent::role::spawn_tool_spec::build( &per_turn_config.agent_roles, @@ -653,6 +656,7 @@ impl Session { .skills_for_config(&skills_input, fs) .await, ); + let goal_tools_supported = !per_turn_config.ephemeral && self.state_db().is_some(); let mut turn_context: TurnContext = Self::make_turn_context( self.conversation_id, Some(Arc::clone(&self.services.auth_manager)), @@ -679,6 +683,7 @@ impl Session { cwd, sub_id, skills_outcome, + goal_tools_supported, ); turn_context.realtime_active = self.conversation.running_state().await.is_some(); diff --git a/codex-rs/core/src/tools/handlers/goal.rs b/codex-rs/core/src/tools/handlers/goal.rs new file mode 100644 index 0000000000..90911fb771 --- /dev/null +++ b/codex-rs/core/src/tools/handlers/goal.rs @@ -0,0 +1,276 @@ +//! Built-in model tool handlers for persisted thread goals. +//! +//! The public tool contract intentionally splits goal creation from completion: +//! `create_goal` starts an active objective, while `update_goal` can only mark +//! the existing goal complete. + +use crate::function_tool::FunctionCallError; +use crate::goals::CreateGoalRequest; +use crate::goals::SetGoalRequest; +use crate::session::session::Session; +use crate::session::turn_context::TurnContext; +use crate::tools::context::FunctionToolOutput; +use crate::tools::context::ToolInvocation; +use crate::tools::context::ToolPayload; +use crate::tools::handlers::parse_arguments; +use crate::tools::registry::ToolHandler; +use crate::tools::registry::ToolKind; +use codex_protocol::protocol::ThreadGoal; +use codex_protocol::protocol::ThreadGoalStatus; +use codex_tools::CREATE_GOAL_TOOL_NAME; +use codex_tools::GET_GOAL_TOOL_NAME; +use codex_tools::UPDATE_GOAL_TOOL_NAME; +use serde::Deserialize; +use serde::Serialize; +use std::fmt::Write as _; + +pub struct GoalHandler; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct CreateGoalArgs { + objective: String, + token_budget: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +struct UpdateGoalArgs { + status: ThreadGoalStatus, +} + +#[derive(Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct GoalToolResponse { + goal: Option, + remaining_tokens: Option, + completion_budget_report: Option, +} + +#[derive(Clone, Copy)] +enum CompletionBudgetReport { + Include, + Omit, +} + +impl GoalToolResponse { + fn new(goal: Option, report_mode: CompletionBudgetReport) -> Self { + let remaining_tokens = goal.as_ref().and_then(|goal| { + goal.token_budget + .map(|budget| (budget - goal.tokens_used).max(0)) + }); + let completion_budget_report = match report_mode { + CompletionBudgetReport::Include => goal + .as_ref() + .filter(|goal| goal.status == ThreadGoalStatus::Complete) + .and_then(completion_budget_report), + CompletionBudgetReport::Omit => None, + }; + Self { + goal, + remaining_tokens, + completion_budget_report, + } + } +} + +impl ToolHandler for GoalHandler { + type Output = FunctionToolOutput; + + fn kind(&self) -> ToolKind { + ToolKind::Function + } + + async fn handle(&self, invocation: ToolInvocation) -> Result { + let ToolInvocation { + session, + turn, + payload, + tool_name, + .. + } = invocation; + + let arguments = match payload { + ToolPayload::Function { arguments } => arguments, + _ => { + return Err(FunctionCallError::RespondToModel( + "goal handler received unsupported payload".to_string(), + )); + } + }; + + match tool_name.name.as_str() { + GET_GOAL_TOOL_NAME => handle_get_goal(session.as_ref()).await, + CREATE_GOAL_TOOL_NAME => { + handle_create_goal(session.as_ref(), turn.as_ref(), &arguments).await + } + UPDATE_GOAL_TOOL_NAME => { + handle_update_goal(session.as_ref(), turn.as_ref(), &arguments).await + } + other => Err(FunctionCallError::Fatal(format!( + "goal handler received unsupported tool: {other}" + ))), + } + } +} + +async fn handle_get_goal(session: &Session) -> Result { + let goal = session + .get_thread_goal() + .await + .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; + goal_response(goal, CompletionBudgetReport::Omit) +} + +async fn handle_create_goal( + session: &Session, + turn_context: &TurnContext, + arguments: &str, +) -> Result { + let args: CreateGoalArgs = parse_arguments(arguments)?; + let goal = session + .create_thread_goal( + turn_context, + CreateGoalRequest { + objective: args.objective, + token_budget: args.token_budget, + }, + ) + .await + .map_err(|err| { + if err + .chain() + .any(|cause| cause.to_string().contains("already has a goal")) + { + FunctionCallError::RespondToModel( + "cannot create a new goal because this thread already has a goal; use update_goal only when the existing goal is complete" + .to_string(), + ) + } else { + FunctionCallError::RespondToModel(format_goal_error(err)) + } + })?; + goal_response(Some(goal), CompletionBudgetReport::Omit) +} + +async fn handle_update_goal( + session: &Session, + turn_context: &TurnContext, + arguments: &str, +) -> Result { + let args: UpdateGoalArgs = parse_arguments(arguments)?; + if args.status != ThreadGoalStatus::Complete { + return Err(FunctionCallError::RespondToModel( + "update_goal can only mark the existing goal complete; pause, resume, and budget-limited status changes are controlled by the user or system" + .to_string(), + )); + } + let goal = session + .set_thread_goal( + turn_context, + SetGoalRequest { + objective: None, + status: Some(ThreadGoalStatus::Complete), + token_budget: None, + }, + ) + .await + .map_err(|err| FunctionCallError::RespondToModel(format_goal_error(err)))?; + goal_response(Some(goal), CompletionBudgetReport::Include) +} + +fn format_goal_error(err: anyhow::Error) -> String { + let mut message = err.to_string(); + for cause in err.chain().skip(1) { + let _ = write!(message, ": {cause}"); + } + message +} + +fn goal_response( + goal: Option, + completion_budget_report: CompletionBudgetReport, +) -> Result { + let response = + serde_json::to_string_pretty(&GoalToolResponse::new(goal, completion_budget_report)) + .map_err(|err| FunctionCallError::Fatal(err.to_string()))?; + Ok(FunctionToolOutput::from_text(response, Some(true))) +} + +fn completion_budget_report(goal: &ThreadGoal) -> Option { + let mut parts = Vec::new(); + if let Some(budget) = goal.token_budget { + parts.push(format!("tokens used: {} of {budget}", goal.tokens_used)); + } + if goal.time_used_seconds > 0 { + parts.push(format!("time used: {} seconds", goal.time_used_seconds)); + } + if parts.is_empty() { + None + } else { + Some(format!( + "Goal achieved. Report final budget usage to the user: {}.", + parts.join("; ") + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_protocol::ThreadId; + use pretty_assertions::assert_eq; + + #[test] + fn completed_budgeted_goal_response_reports_final_usage() { + let goal = ThreadGoal { + thread_id: ThreadId::new(), + objective: "Keep optimizing".to_string(), + status: ThreadGoalStatus::Complete, + token_budget: Some(10_000), + tokens_used: 3_250, + time_used_seconds: 75, + created_at: 1, + updated_at: 2, + }; + + let response = GoalToolResponse::new(Some(goal.clone()), CompletionBudgetReport::Include); + + assert_eq!( + response, + GoalToolResponse { + goal: Some(goal), + remaining_tokens: Some(6_750), + completion_budget_report: Some( + "Goal achieved. Report final budget usage to the user: tokens used: 3250 of 10000; time used: 75 seconds." + .to_string() + ), + } + ); + } + + #[test] + fn completed_unbudgeted_goal_response_omits_budget_report() { + let goal = ThreadGoal { + thread_id: ThreadId::new(), + objective: "Write a poem".to_string(), + status: ThreadGoalStatus::Complete, + token_budget: None, + tokens_used: 120, + time_used_seconds: 0, + created_at: 1, + updated_at: 2, + }; + + let response = GoalToolResponse::new(Some(goal.clone()), CompletionBudgetReport::Include); + + assert_eq!( + response, + GoalToolResponse { + goal: Some(goal), + remaining_tokens: None, + completion_budget_report: None, + } + ); + } +} diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 757b0d94bd..f96b49ad42 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -1,6 +1,7 @@ pub(crate) mod agent_jobs; pub(crate) mod apply_patch; mod dynamic; +mod goal; mod list_dir; mod mcp; mod mcp_resource; @@ -36,6 +37,7 @@ pub use apply_patch::ApplyPatchHandler; use codex_protocol::models::AdditionalPermissionProfile; use codex_protocol::protocol::AskForApproval; pub use dynamic::DynamicToolHandler; +pub use goal::GoalHandler; pub use list_dir::ListDirHandler; pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index e556cab30f..ebbc38b8be 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -80,6 +80,7 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; use crate::tools::handlers::DynamicToolHandler; + use crate::tools::handlers::GoalHandler; use crate::tools::handlers::ListDirHandler; use crate::tools::handlers::McpHandler; use crate::tools::handlers::McpResourceHandler; @@ -148,6 +149,7 @@ pub(crate) fn build_specs_with_discoverable_tools( let plan_handler = Arc::new(PlanHandler); let apply_patch_handler = Arc::new(ApplyPatchHandler); let dynamic_tool_handler = Arc::new(DynamicToolHandler); + let goal_handler = Arc::new(GoalHandler); let view_image_handler = Arc::new(ViewImageHandler); let mcp_handler = Arc::new(McpHandler); let mcp_resource_handler = Arc::new(McpResourceHandler); @@ -208,6 +210,9 @@ pub(crate) fn build_specs_with_discoverable_tools( ToolHandlerKind::FollowupTaskV2 => { builder.register_handler(handler.name, Arc::new(FollowupTaskHandlerV2)); } + ToolHandlerKind::Goal => { + builder.register_handler(handler.name, goal_handler.clone()); + } ToolHandlerKind::ListAgentsV2 => { builder.register_handler(handler.name, Arc::new(ListAgentsHandlerV2)); } diff --git a/codex-rs/tools/src/goal_tool.rs b/codex-rs/tools/src/goal_tool.rs new file mode 100644 index 0000000000..489fd8db34 --- /dev/null +++ b/codex-rs/tools/src/goal_tool.rs @@ -0,0 +1,112 @@ +//! Responses API tool definitions for persisted thread goals. +//! +//! These specs expose goal read/update primitives to the model while keeping +//! usage accounting system-managed. + +use crate::JsonSchema; +use crate::ResponsesApiTool; +use crate::ToolSpec; +use serde_json::json; +use std::collections::BTreeMap; + +pub const GET_GOAL_TOOL_NAME: &str = "get_goal"; +pub const CREATE_GOAL_TOOL_NAME: &str = "create_goal"; +pub const UPDATE_GOAL_TOOL_NAME: &str = "update_goal"; + +pub fn create_get_goal_tool() -> ToolSpec { + ToolSpec::Function(ResponsesApiTool { + name: GET_GOAL_TOOL_NAME.to_string(), + description: "Get the current goal for this thread, including status, budgets, token and elapsed-time usage, and remaining token budget." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object(BTreeMap::new(), Some(Vec::new()), Some(false.into())), + output_schema: None, + }) +} + +pub fn create_create_goal_tool() -> ToolSpec { + let properties = BTreeMap::from([ + ( + "objective".to_string(), + JsonSchema::string(Some( + "Required. The concrete objective to start pursuing. This starts a new active goal only when no goal is currently defined; if a goal already exists, this tool fails." + .to_string(), + )), + ), + ( + "token_budget".to_string(), + JsonSchema::integer(Some( + "Optional positive token budget for the new active goal.".to_string(), + )), + ), + ]); + + ToolSpec::Function(ResponsesApiTool { + name: CREATE_GOAL_TOOL_NAME.to_string(), + description: format!( + r#"Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks. +Set token_budget only when an explicit token budget is requested. Fails if a goal exists; use {UPDATE_GOAL_TOOL_NAME} only for status."# + ), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + properties, + /*required*/ Some(vec!["objective".to_string()]), + Some(false.into()), + ), + output_schema: None, + }) +} + +pub fn create_update_goal_tool() -> ToolSpec { + let properties = BTreeMap::from([( + "status".to_string(), + JsonSchema::string_enum( + vec![json!("complete")], + Some( + "Required. Set to complete only when the objective is achieved and no required work remains." + .to_string(), + ), + ), + )]); + + ToolSpec::Function(ResponsesApiTool { + name: UPDATE_GOAL_TOOL_NAME.to_string(), + description: r#"Update the existing goal. +Use this tool only to mark the goal achieved. +Set status to `complete` only when the objective has actually been achieved and no required work remains. +Do not mark a goal complete merely because its budget is nearly exhausted or because you are stopping work. +You cannot use this tool to pause, resume, or budget-limit a goal; those status changes are controlled by the user or system. +When marking a budgeted goal achieved with status `complete`, report the final token usage from the tool result to the user."# + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + properties, + /*required*/ Some(vec!["status".to_string()]), + Some(false.into()), + ), + output_schema: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn update_goal_tool_only_exposes_complete_status() { + let ToolSpec::Function(tool) = create_update_goal_tool() else { + panic!("update_goal should be a function tool"); + }; + let status = tool + .parameters + .properties + .as_ref() + .and_then(|properties| properties.get("status")) + .expect("status property should exist"); + + assert_eq!(status.enum_values, Some(vec![json!("complete")])); + } +} diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index fe140e4791..516bda6859 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -6,6 +6,7 @@ mod agent_tool; mod apply_patch_tool; mod code_mode; mod dynamic_tool; +mod goal_tool; mod image_detail; mod json_schema; mod local_tool; @@ -51,6 +52,12 @@ pub use code_mode::create_wait_tool; pub use code_mode::tool_spec_to_code_mode_tool_definition; pub use codex_protocol::ToolName; pub use dynamic_tool::parse_dynamic_tool; +pub use goal_tool::CREATE_GOAL_TOOL_NAME; +pub use goal_tool::GET_GOAL_TOOL_NAME; +pub use goal_tool::UPDATE_GOAL_TOOL_NAME; +pub use goal_tool::create_create_goal_tool; +pub use goal_tool::create_get_goal_tool; +pub use goal_tool::create_update_goal_tool; pub use image_detail::can_request_original_image_detail; pub use image_detail::normalize_output_image_detail; pub use image_detail::sanitize_original_image_detail; diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index 8f27578c5d..7520beeaec 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -101,6 +101,7 @@ pub struct ToolsConfig { pub code_mode_only_enabled: bool, pub can_request_original_image_detail: bool, pub collab_tools: bool, + pub goal_tools: bool, pub multi_agent_v2: bool, pub hide_spawn_agent_metadata: bool, pub spawn_agent_usage_hint: bool, @@ -140,6 +141,7 @@ impl ToolsConfig { let include_code_mode = features.enabled(Feature::CodeMode); let include_code_mode_only = include_code_mode && features.enabled(Feature::CodeModeOnly); let include_collab_tools = features.enabled(Feature::Collab); + let include_goal_tools = features.enabled(Feature::Goals); let include_multi_agent_v2 = features.enabled(Feature::MultiAgentV2); let include_agent_jobs = features.enabled(Feature::SpawnCsv); let include_default_mode_request_user_input = @@ -218,6 +220,7 @@ impl ToolsConfig { code_mode_only_enabled: include_code_mode_only, can_request_original_image_detail: include_original_image_detail, collab_tools: include_collab_tools, + goal_tools: include_goal_tools, multi_agent_v2: include_multi_agent_v2, hide_spawn_agent_metadata: false, spawn_agent_usage_hint: true, @@ -254,6 +257,11 @@ impl ToolsConfig { self } + pub fn with_goal_tools_allowed(mut self, allowed: bool) -> Self { + self.goal_tools = self.goal_tools && allowed; + self + } + pub fn with_max_concurrent_threads_per_session( mut self, max_concurrent_threads_per_session: Option, diff --git a/codex-rs/tools/src/tool_registry_plan.rs b/codex-rs/tools/src/tool_registry_plan.rs index 6b024658f3..f7c10dc2c4 100644 --- a/codex-rs/tools/src/tool_registry_plan.rs +++ b/codex-rs/tools/src/tool_registry_plan.rs @@ -26,8 +26,10 @@ use crate::create_apply_patch_json_tool; use crate::create_close_agent_tool_v1; use crate::create_close_agent_tool_v2; use crate::create_code_mode_tool; +use crate::create_create_goal_tool; use crate::create_exec_command_tool; use crate::create_followup_task_tool; +use crate::create_get_goal_tool; use crate::create_image_generation_tool; use crate::create_list_agents_tool; use crate::create_list_dir_tool; @@ -49,6 +51,7 @@ use crate::create_spawn_agents_on_csv_tool; use crate::create_test_sync_tool; use crate::create_tool_search_tool; use crate::create_tool_suggest_tool; +use crate::create_update_goal_tool; use crate::create_update_plan_tool; use crate::create_view_image_tool; use crate::create_wait_agent_tool_v1; @@ -215,6 +218,26 @@ pub fn build_tool_registry_plan( config.code_mode_enabled, ); plan.register_handler("update_plan", ToolHandlerKind::Plan); + if config.goal_tools { + plan.push_spec( + create_get_goal_tool(), + /*supports_parallel_tool_calls*/ false, + config.code_mode_enabled, + ); + plan.register_handler("get_goal", ToolHandlerKind::Goal); + plan.push_spec( + create_create_goal_tool(), + /*supports_parallel_tool_calls*/ false, + config.code_mode_enabled, + ); + plan.register_handler("create_goal", ToolHandlerKind::Goal); + plan.push_spec( + create_update_goal_tool(), + /*supports_parallel_tool_calls*/ false, + config.code_mode_enabled, + ); + plan.register_handler("update_goal", ToolHandlerKind::Goal); + } plan.push_spec( create_request_user_input_tool(request_user_input_tool_description( diff --git a/codex-rs/tools/src/tool_registry_plan_tests.rs b/codex-rs/tools/src/tool_registry_plan_tests.rs index c08666a2c2..24a1ea9ce5 100644 --- a/codex-rs/tools/src/tool_registry_plan_tests.rs +++ b/codex-rs/tools/src/tool_registry_plan_tests.rs @@ -104,6 +104,15 @@ fn test_full_toolset_specs_for_gpt5_codex_unified_exec_web_search() { ] { expected.insert(spec.name().to_string(), spec); } + if config.goal_tools { + for spec in [ + create_get_goal_tool(), + create_create_goal_tool(), + create_update_goal_tool(), + ] { + expected.insert(spec.name().to_string(), spec); + } + } let collab_specs = if config.multi_agent_v2 { vec![ create_spawn_agent_tool_v2(spawn_agent_tool_options(&config)), @@ -186,6 +195,51 @@ fn test_build_specs_collab_tools_enabled() { assert!(!properties.contains_key("fork_turns")); } +#[test] +fn goal_tools_require_goals_feature() { + let model_info = model_info(); + let available_models = Vec::new(); + let mut features = Features::with_defaults(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*deferred_mcp_tools*/ None, + &[], + ); + assert_lacks_tool_name(&tools, "get_goal"); + assert_lacks_tool_name(&tools, "create_goal"); + assert_lacks_tool_name(&tools, "update_goal"); + + features.enable(Feature::Goals); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + image_generation_tool_auth_allowed: true, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs( + &tools_config, + /*mcp_tools*/ None, + /*deferred_mcp_tools*/ None, + &[], + ); + assert_contains_tool_names(&tools, &["get_goal", "create_goal", "update_goal"]); +} + #[test] fn test_build_specs_multi_agent_v2_uses_task_names_and_hides_resume() { let model_info = model_info(); diff --git a/codex-rs/tools/src/tool_registry_plan_types.rs b/codex-rs/tools/src/tool_registry_plan_types.rs index 724f1bd15d..d22335b614 100644 --- a/codex-rs/tools/src/tool_registry_plan_types.rs +++ b/codex-rs/tools/src/tool_registry_plan_types.rs @@ -18,6 +18,7 @@ pub enum ToolHandlerKind { CodeModeWait, DynamicTool, FollowupTaskV2, + Goal, ListAgentsV2, ListDir, Mcp, From 4167628622a0af70374a7a6c44a547a99b5075eb Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 24 Apr 2026 21:16:00 -0700 Subject: [PATCH 16/67] Add goal core runtime (4 / 5) (#18076) Adds the core runtime behavior for active goals on top of the model tools from PR 3. ## Why A long-running goal should be a core runtime concern, not something every client has to implement. Core owns the turn lifecycle, tool completion boundaries, interruptions, resume behavior, and token usage, so it is the right place to account progress, enforce budgets, and decide when to continue work. ## What changed - Centralized goal lifecycle side effects behind `Session::goal_runtime_apply(GoalRuntimeEvent::...)`. - Starts goal continuation turns only when the session is idle; pending user input and mailbox work take priority. - Accounts token and wall-clock usage at turn, tool, mutation, interrupt, and resume boundaries; `get_thread_goal` remains read-only. - Preserves sub-second wall-clock remainder across accounting boundaries so long-running goals do not drift downward over time. - Treats token budget exhaustion as a soft stop by marking the goal `budget_limited` and injecting wrap-up steering instead of aborting the active turn. - Suppresses budget steering when `update_goal` marks a goal complete. - Pauses active goals on interrupt and auto-reactivates paused goals when a thread resumes outside plan mode. - Suppresses repeated automatic continuation when a continuation turn makes no tool calls. - Added continuation and budget-limit prompt templates. ## Verification - Added focused core coverage for continuation scheduling, accounting boundaries, budget-limit steering, completion accounting, interrupt pause behavior, resume auto-activation, and wall-clock remainder accounting. --- .../app-server/src/codex_message_processor.rs | 18 + .../thread_goal_handlers.rs | 25 +- .../tests/suite/v2/thread_resume.rs | 11 +- codex-rs/core/src/codex_thread.rs | 48 + codex-rs/core/src/goals.rs | 1436 ++++++++++++++++- codex-rs/core/src/session/mod.rs | 8 +- codex-rs/core/src/session/session.rs | 3 + codex-rs/core/src/session/tests.rs | 631 +++++++- codex-rs/core/src/state/turn.rs | 6 +- codex-rs/core/src/tasks/mod.rs | 97 +- codex-rs/core/src/thread_manager.rs | 12 +- codex-rs/core/src/thread_manager_tests.rs | 94 ++ codex-rs/core/src/tools/handlers/goal.rs | 12 +- codex-rs/core/src/tools/registry.rs | 12 + codex-rs/core/templates/goals/budget_limit.md | 16 + codex-rs/core/templates/goals/continuation.md | 28 + codex-rs/protocol/src/protocol.rs | 14 + 17 files changed, 2360 insertions(+), 111 deletions(-) create mode 100644 codex-rs/core/templates/goals/budget_limit.md create mode 100644 codex-rs/core/templates/goals/continuation.md diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index dfec182fd0..4d31fd1eaf 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -4722,6 +4722,11 @@ impl CodexMessageProcessor { } if self.config.features.enabled(Feature::Goals) { self.emit_thread_goal_snapshot(thread_id).await; + // App-server owns resume response and snapshot ordering, so wait + // until those are sent before letting core start goal continuation. + if let Err(err) = codex_thread.continue_active_goal_if_idle().await { + tracing::warn!("failed to continue active goal after resume: {err}"); + } } } Err(err) => { @@ -8980,6 +8985,12 @@ async fn handle_pending_thread_resume_request( } } + if pending.emit_thread_goal_update + && let Err(err) = conversation.apply_goal_resume_runtime_effects().await + { + tracing::warn!("failed to apply goal resume runtime effects: {err}"); + } + let ThreadConfigSnapshot { model, model_provider_id, @@ -9042,6 +9053,13 @@ async fn handle_pending_thread_resume_request( outgoing .replay_requests_to_connection_for_thread(connection_id, conversation_id) .await; + // App-server owns resume response and snapshot ordering, so wait until + // replay completes before letting core start goal continuation. + if pending.emit_thread_goal_update + && let Err(err) = conversation.continue_active_goal_if_idle().await + { + tracing::warn!("failed to continue active goal after running-thread resume: {err}"); + } } async fn send_thread_goal_snapshot_notification( diff --git a/codex-rs/app-server/src/codex_message_processor/thread_goal_handlers.rs b/codex-rs/app-server/src/codex_message_processor/thread_goal_handlers.rs index f837ef9dc3..049e0af21c 100644 --- a/codex-rs/app-server/src/codex_message_processor/thread_goal_handlers.rs +++ b/codex-rs/app-server/src/codex_message_processor/thread_goal_handlers.rs @@ -1,4 +1,5 @@ use super::*; +use codex_protocol::protocol::validate_thread_goal_objective; impl CodexMessageProcessor { pub(super) async fn thread_goal_set( @@ -83,12 +84,8 @@ impl CodexMessageProcessor { let objective = params.objective.as_deref().map(str::trim); if let Some(objective) = objective { - if objective.is_empty() { - self.send_invalid_request_error( - request_id, - "goal objective must not be empty".to_string(), - ) - .await; + if let Err(message) = validate_thread_goal_objective(objective) { + self.send_invalid_request_error(request_id, message).await; return; } if let Err(message) = validate_goal_budget(params.token_budget.flatten()) { @@ -102,6 +99,10 @@ impl CodexMessageProcessor { return; } + if let Some(thread) = running_thread.as_ref() { + thread.prepare_external_goal_mutation().await; + } + let goal = if let Some(objective) = objective { match state_db.get_thread_goal(thread_id).await { Ok(goal) => { @@ -165,6 +166,7 @@ impl CodexMessageProcessor { return; } }; + let goal_status = goal.status; let goal = api_thread_goal_from_state(goal); self.outgoing .send_response( @@ -174,6 +176,9 @@ impl CodexMessageProcessor { .await; self.emit_thread_goal_updated_ordered(thread_id, goal, listener_command_tx) .await; + if let Some(thread) = running_thread.as_ref() { + thread.apply_external_goal_set(goal_status).await; + } } pub(super) async fn thread_goal_get( @@ -287,6 +292,10 @@ impl CodexMessageProcessor { ) .await; + if let Some(thread) = running_thread.as_ref() { + thread.prepare_external_goal_mutation().await; + } + let listener_command_tx = { let thread_state = self.thread_state_manager.thread_state(thread_id).await; let thread_state = thread_state.lock().await; @@ -301,6 +310,10 @@ impl CodexMessageProcessor { } }; + if cleared && let Some(thread) = running_thread.as_ref() { + thread.apply_external_goal_clear().await; + } + self.outgoing .send_response(request_id, ThreadGoalClearResponse { cleared }) .await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index f3d3923759..6e85c4ee47 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -387,7 +387,7 @@ async fn thread_resume_can_skip_turns_for_metadata_only_resume() -> Result<()> { } #[tokio::test] -async fn thread_resume_emits_paused_goal_update() -> Result<()> { +async fn thread_resume_emits_active_goal_update_before_continuation() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; @@ -459,6 +459,7 @@ async fn thread_resume_emits_paused_goal_update() -> Result<()> { mcp.read_stream_until_notification_message("thread/goal/updated"), ) .await??; + mcp.clear_message_buffer(); let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { @@ -481,7 +482,13 @@ async fn thread_resume_emits_paused_goal_update() -> Result<()> { let ServerNotification::ThreadGoalUpdated(notification) = notification else { anyhow::bail!("expected thread goal update notification"); }; - assert_eq!(notification.goal.status, ThreadGoalStatus::Paused); + assert_eq!(notification.goal.status, ThreadGoalStatus::Active); + assert!( + !mcp.pending_notification_methods() + .iter() + .any(|method| method == "turn/started"), + "goal continuation should start only after the resume goal snapshot" + ); Ok(()) } diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index cda2d22fb0..a32cda4a14 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -1,6 +1,7 @@ use crate::agent::AgentStatus; use crate::config::ConstraintResult; use crate::file_watcher::WatchRegistration; +use crate::goals::GoalRuntimeEvent; use crate::session::Codex; use crate::session::SessionSettingsUpdate; use crate::session::SteerInputError; @@ -103,6 +104,53 @@ impl CodexThread { self.codex.shutdown_and_wait().await } + pub async fn apply_goal_resume_runtime_effects(&self) -> anyhow::Result<()> { + self.codex + .session + .goal_runtime_apply(GoalRuntimeEvent::ThreadResumed) + .await + } + + pub async fn continue_active_goal_if_idle(&self) -> anyhow::Result<()> { + self.codex + .session + .goal_runtime_apply(GoalRuntimeEvent::MaybeContinueIfIdle) + .await + } + + pub async fn prepare_external_goal_mutation(&self) { + if let Err(err) = self + .codex + .session + .goal_runtime_apply(GoalRuntimeEvent::ExternalMutationStarting) + .await + { + tracing::warn!("failed to prepare external goal mutation: {err}"); + } + } + + pub async fn apply_external_goal_set(&self, status: codex_state::ThreadGoalStatus) { + if let Err(err) = self + .codex + .session + .goal_runtime_apply(GoalRuntimeEvent::ExternalSet { status }) + .await + { + tracing::warn!("failed to apply external goal status runtime effects: {err}"); + } + } + + pub async fn apply_external_goal_clear(&self) { + if let Err(err) = self + .codex + .session + .goal_runtime_apply(GoalRuntimeEvent::ExternalClear) + .await + { + tracing::warn!("failed to apply external goal clear runtime effects: {err}"); + } + } + #[doc(hidden)] pub async fn ensure_rollout_materialized(&self) { self.codex.session.ensure_rollout_materialized().await; diff --git a/codex-rs/core/src/goals.rs b/codex-rs/core/src/goals.rs index 18fa8da0ae..f3c64f1b3a 100644 --- a/codex-rs/core/src/goals.rs +++ b/codex-rs/core/src/goals.rs @@ -7,14 +7,35 @@ use crate::StateDbHandle; use crate::session::session::Session; use crate::session::turn_context::TurnContext; +use crate::state::ActiveTurn; +use crate::state::TurnState; +use crate::tasks::RegularTask; use anyhow::Context; use codex_features::Feature; +use codex_protocol::config_types::ModeKind; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseInputItem; +use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ThreadGoal; use codex_protocol::protocol::ThreadGoalStatus; use codex_protocol::protocol::ThreadGoalUpdatedEvent; +use codex_protocol::protocol::TokenUsage; +use codex_protocol::protocol::TurnAbortReason; +use codex_protocol::protocol::validate_thread_goal_objective; use codex_rollout::state_db::reconcile_rollout; use codex_thread_store::LocalThreadStore; +use codex_utils_template::Template; +use futures::future::BoxFuture; +use std::sync::Arc; +use std::sync::LazyLock; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::time::Duration; +use std::time::Instant; +use tokio::sync::Mutex; +use tokio::sync::Semaphore; +use tokio::sync::SemaphorePermit; pub(crate) struct SetGoalRequest { pub(crate) objective: Option, @@ -27,13 +48,318 @@ pub(crate) struct CreateGoalRequest { pub(crate) token_budget: Option, } +static CONTINUATION_PROMPT_TEMPLATE: LazyLock