diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 0fc0747dda..45322e4ffa 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -4,3 +4,5 @@ Before opening this Pull Request, please read the dedicated "Contributing" markd https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. + +Include a link to a bug report or enhancement request. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fa732a492..38773bb9f1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT" - name: Upload staged npm package artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 with: name: codex-npm-staging path: ${{ steps.stage_npm_package.outputs.pack_output }} diff --git a/.github/workflows/issue-deduplicator.yml b/.github/workflows/issue-deduplicator.yml index 2bac731e61..c36857cab0 100644 --- a/.github/workflows/issue-deduplicator.yml +++ b/.github/workflows/issue-deduplicator.yml @@ -16,7 +16,7 @@ jobs: outputs: codex_output: ${{ steps.codex.outputs.final-message }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Prepare Codex inputs env: @@ -87,7 +87,7 @@ jobs: issues: write steps: - name: Comment on issue - uses: actions/github-script@v7 + uses: actions/github-script@v8 env: CODEX_OUTPUT: ${{ needs.gather-duplicates.outputs.codex_output }} with: diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml index 7560967ea4..39f9d47f14 100644 --- a/.github/workflows/issue-labeler.yml +++ b/.github/workflows/issue-labeler.yml @@ -16,7 +16,7 @@ jobs: outputs: codex_output: ${{ steps.codex.outputs.final-message }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - id: codex uses: openai/codex-action@main @@ -26,21 +26,36 @@ jobs: prompt: | You are an assistant that reviews GitHub issues for the repository. - Your job is to choose the most appropriate existing labels for the issue described later in this prompt. + Your job is to choose the most appropriate labels for the issue described later in this prompt. Follow these rules: - - Only pick labels out of the list below. - - Prefer a small set of precise labels over many broad ones. - Labels to apply: + - Add one (and only one) of the following three labels to distinguish the type of issue. Default to "bug" if unsure. 1. bug — Reproducible defects in Codex products (CLI, VS Code extension, web, auth). 2. enhancement — Feature requests or usability improvements that ask for new capabilities, better ergonomics, or quality-of-life tweaks. - 3. extension — VS Code (or other IDE) extension-specific issues. - 4. windows-os — Bugs or friction specific to Windows environments (always when PowerShell is mentioned, path handling, copy/paste, OS-specific auth or tooling failures). - 5. mcp — Topics involving Model Context Protocol servers/clients. - 6. codex-web — Issues targeting the Codex web UI/Cloud experience. - 8. azure — Problems or requests tied to Azure OpenAI deployments. - 9. documentation — Updates or corrections needed in docs/README/config references (broken links, missing examples, outdated keys, clarification requests). - 10. model-behavior — Undesirable LLM behavior: forgetting goals, refusing work, hallucinating environment details, quota misreports, or other reasoning/performance anomalies. + 3. documentation — Updates or corrections needed in docs/README/config references (broken links, missing examples, outdated keys, clarification requests). + + - If applicable, add one of the following labels to specify which sub-product or product surface the issue relates to. + 1. CLI — the Codex command line interface. + 2. extension — VS Code (or other IDE) extension-specific issues. + 3. codex-web — Issues targeting the Codex web UI/Cloud experience. + 4. github-action — Issues with the Codex GitHub action. + 5. iOS — Issues with the Codex iOS app. + + - Additionally add zero or more of the following labels that are relevant to the issue content. Prefer a small set of precise labels over many broad ones. + 1. windows-os — Bugs or friction specific to Windows environments (always when PowerShell is mentioned, path handling, copy/paste, OS-specific auth or tooling failures). + 2. mcp — Topics involving Model Context Protocol servers/clients. + 3. mcp-server — Problems related to the codex mcp-server command, where codex runs as an MCP server. + 4. azure — Problems or requests tied to Azure OpenAI deployments. + 5. model-behavior — Undesirable LLM behavior: forgetting goals, refusing work, hallucinating environment details, quota misreports, or other reasoning/performance anomalies. + 6. code-review — Issues related to the code review feature or functionality. + 7. auth - Problems related to authentication, login, or access tokens. + 8. codex-exec - Problems related to the "codex exec" command or functionality. + 9. context-management - Problems related to compaction, context windows, or available context reporting. + 10. custom-model - Problems that involve using custom model providers, local models, or OSS models. + 11. rate-limits - Problems related to token limits, rate limits, or token usage reporting. + 12. sandbox - Issues related to local sandbox environments or tool call approvals to override sandbox restrictions. + 13. tool-calls - Problems related to specific tool call invocations including unexpected errors, failures, or hangs. + 14. TUI - Problems with the terminal user interface (TUI) including keyboard shortcuts, copy & pasting, menus, or screen update issues. Issue number: ${{ github.event.issue.number }} diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index cbf62f72f0..103e8cf6bd 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -9,7 +9,7 @@ on: # CI builds in debug (dev) for faster signal. jobs: - # --- Detect what changed (always runs) ------------------------------------- + # --- Detect what changed to detect which tests to run (always runs) ------------------------------------- changed: name: Detect changed areas runs-on: ubuntu-24.04 @@ -84,8 +84,8 @@ jobs: run: cargo shear # --- CI to validate on different os/targets -------------------------------- - lint_build_test: - name: ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.profile == 'release' && ' (release)' || '' }} + lint_build: + name: Lint/Build — ${{ matrix.runner }} - ${{ matrix.target }}${{ matrix.profile == 'release' && ' (release)' || '' }} runs-on: ${{ matrix.runner }} timeout-minutes: 30 needs: changed @@ -94,6 +94,11 @@ jobs: defaults: run: working-directory: codex-rs + env: + # Speed up repeated builds across CI runs by caching compiled objects. + RUSTC_WRAPPER: sccache + CARGO_INCREMENTAL: "0" + SCCACHE_CACHE_SIZE: 10G strategy: fail-fast: false @@ -159,20 +164,83 @@ jobs: ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ - key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }} + key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}-${{ hashFiles('codex-rs/rust-toolchain.toml') }} + restore-keys: | + cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- - - name: Restore target cache (except gnu-dev) - id: cache_target_restore - if: ${{ !(matrix.target == 'x86_64-unknown-linux-gnu' && matrix.profile != 'release') }} + # Install and restore sccache cache + - name: Install sccache + uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2 + with: + tool: sccache + version: 0.7.5 + + - name: Configure sccache backend + shell: bash + run: | + set -euo pipefail + if [[ -n "${ACTIONS_CACHE_URL:-}" && -n "${ACTIONS_RUNTIME_TOKEN:-}" ]]; then + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "Using sccache GitHub backend" + else + echo "SCCACHE_GHA_ENABLED=false" >> "$GITHUB_ENV" + echo "SCCACHE_DIR=${{ github.workspace }}/.sccache" >> "$GITHUB_ENV" + echo "Using sccache local disk + actions/cache fallback" + fi + + - name: Restore sccache cache (fallback) + if: ${{ env.SCCACHE_GHA_ENABLED != 'true' }} + id: cache_sccache_restore uses: actions/cache/restore@v4 with: - path: ${{ github.workspace }}/codex-rs/target/ - key: cargo-target-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }} + path: ${{ github.workspace }}/.sccache/ + key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}-${{ github.run_id }} + restore-keys: | + sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}- + sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- + + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} + name: Prepare APT cache directories (musl) + shell: bash + run: | + set -euo pipefail + sudo mkdir -p /var/cache/apt/archives /var/lib/apt/lists + sudo chown -R "$USER:$USER" /var/cache/apt /var/lib/apt/lists + + - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} + name: Restore APT cache (musl) + id: cache_apt_restore + uses: actions/cache/restore@v4 + with: + path: | + /var/cache/apt + key: apt-${{ matrix.runner }}-${{ matrix.target }}-v1 - if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl'}} name: Install musl build tools + env: + DEBIAN_FRONTEND: noninteractive + shell: bash run: | - sudo apt install -y musl-tools pkg-config && sudo rm -rf /var/lib/apt/lists/* + set -euo pipefail + sudo apt-get -y update -o Acquire::Retries=3 + sudo apt-get -y install --no-install-recommends musl-tools pkg-config + + - name: Install cargo-chef + if: ${{ matrix.profile == 'release' }} + uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2 + with: + tool: cargo-chef + version: 0.1.71 + + - name: Pre-warm dependency cache (cargo-chef) + if: ${{ matrix.profile == 'release' }} + shell: bash + run: | + set -euo pipefail + RECIPE="${RUNNER_TEMP}/chef-recipe.json" + cargo chef prepare --recipe-path "$RECIPE" + cargo chef cook --recipe-path "$RECIPE" --target ${{ matrix.target }} --release --all-features - name: cargo clippy id: clippy @@ -191,20 +259,6 @@ jobs: find . -name Cargo.toml -mindepth 2 -maxdepth 2 -print0 \ | xargs -0 -n1 -I{} bash -c 'cd "$(dirname "{}")" && cargo check --profile ${{ matrix.profile }}' - - uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2 - with: - tool: nextest - version: 0.9.103 - - - name: tests - id: test - # Tests take too long for release builds to run them on every PR. - if: ${{ matrix.profile != 'release' }} - continue-on-error: true - run: cargo nextest run --all-features --no-fail-fast --target ${{ matrix.target }} - env: - RUST_BACKTRACE: 1 - # Save caches explicitly; make non-fatal so cache packaging # never fails the overall job. Only save when key wasn't hit. - name: Save cargo home cache @@ -217,33 +271,193 @@ jobs: ~/.cargo/registry/index/ ~/.cargo/registry/cache/ ~/.cargo/git/db/ - key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }} + key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}-${{ hashFiles('codex-rs/rust-toolchain.toml') }} - - name: Save target cache (except gnu-dev) - if: >- - always() && !cancelled() && - (steps.cache_target_restore.outputs.cache-hit != 'true') && - !(matrix.target == 'x86_64-unknown-linux-gnu' && matrix.profile != 'release') + - name: Save sccache cache (fallback) + if: always() && !cancelled() && env.SCCACHE_GHA_ENABLED != 'true' continue-on-error: true uses: actions/cache/save@v4 with: - path: ${{ github.workspace }}/codex-rs/target/ - key: cargo-target-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }} + path: ${{ github.workspace }}/.sccache/ + key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}-${{ github.run_id }} + + - name: sccache stats + if: always() + continue-on-error: true + run: sccache --show-stats || true + + - name: sccache summary + if: always() + shell: bash + run: | + { + echo "### sccache stats — ${{ matrix.target }} (${{ matrix.profile }})"; + echo; + echo '```'; + sccache --show-stats || true; + echo '```'; + } >> "$GITHUB_STEP_SUMMARY" + + - name: Save APT cache (musl) + if: always() && !cancelled() && (matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl') && steps.cache_apt_restore.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@v4 + with: + path: | + /var/cache/apt + key: apt-${{ matrix.runner }}-${{ matrix.target }}-v1 # Fail the job if any of the previous steps failed. - name: verify all steps passed if: | steps.clippy.outcome == 'failure' || - steps.cargo_check_all_crates.outcome == 'failure' || - steps.test.outcome == 'failure' + steps.cargo_check_all_crates.outcome == 'failure' run: | - echo "One or more checks failed (clippy, cargo_check_all_crates, or test). See logs for details." + echo "One or more checks failed (clippy or cargo_check_all_crates). See logs for details." + exit 1 + + tests: + name: Tests — ${{ matrix.runner }} - ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + needs: changed + if: ${{ needs.changed.outputs.codex == 'true' || needs.changed.outputs.workflows == 'true' || github.event_name == 'push' }} + defaults: + run: + working-directory: codex-rs + env: + RUSTC_WRAPPER: sccache + CARGO_INCREMENTAL: "0" + SCCACHE_CACHE_SIZE: 10G + + strategy: + fail-fast: false + matrix: + include: + - runner: macos-14 + target: aarch64-apple-darwin + profile: dev + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-gnu + profile: dev + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-gnu + profile: dev + - runner: windows-latest + target: x86_64-pc-windows-msvc + profile: dev + - runner: windows-11-arm + target: aarch64-pc-windows-msvc + profile: dev + + steps: + - uses: actions/checkout@v5 + - uses: dtolnay/rust-toolchain@1.90 + with: + targets: ${{ matrix.target }} + + - name: Restore cargo home cache + id: cache_cargo_home_restore + uses: actions/cache/restore@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}-${{ hashFiles('codex-rs/rust-toolchain.toml') }} + restore-keys: | + cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- + + - name: Install sccache + uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2 + with: + tool: sccache + version: 0.7.5 + + - name: Configure sccache backend + shell: bash + run: | + set -euo pipefail + if [[ -n "${ACTIONS_CACHE_URL:-}" && -n "${ACTIONS_RUNTIME_TOKEN:-}" ]]; then + echo "SCCACHE_GHA_ENABLED=true" >> "$GITHUB_ENV" + echo "Using sccache GitHub backend" + else + echo "SCCACHE_GHA_ENABLED=false" >> "$GITHUB_ENV" + echo "SCCACHE_DIR=${{ github.workspace }}/.sccache" >> "$GITHUB_ENV" + echo "Using sccache local disk + actions/cache fallback" + fi + + - name: Restore sccache cache (fallback) + if: ${{ env.SCCACHE_GHA_ENABLED != 'true' }} + id: cache_sccache_restore + uses: actions/cache/restore@v4 + with: + path: ${{ github.workspace }}/.sccache/ + key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}-${{ github.run_id }} + restore-keys: | + sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}- + sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}- + + - uses: taiki-e/install-action@0c5db7f7f897c03b771660e91d065338615679f4 # v2 + with: + tool: nextest + version: 0.9.103 + + - name: tests + id: test + continue-on-error: true + run: cargo nextest run --all-features --no-fail-fast --target ${{ matrix.target }} --cargo-profile ci-test + env: + RUST_BACKTRACE: 1 + + - name: Save cargo home cache + if: always() && !cancelled() && steps.cache_cargo_home_restore.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + key: cargo-home-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}-${{ hashFiles('codex-rs/rust-toolchain.toml') }} + + - name: Save sccache cache (fallback) + if: always() && !cancelled() && env.SCCACHE_GHA_ENABLED != 'true' + continue-on-error: true + uses: actions/cache/save@v4 + with: + path: ${{ github.workspace }}/.sccache/ + key: sccache-${{ matrix.runner }}-${{ matrix.target }}-${{ matrix.profile }}-${{ hashFiles('**/Cargo.lock') }}-${{ github.run_id }} + + - name: sccache stats + if: always() + continue-on-error: true + run: sccache --show-stats || true + + - name: sccache summary + if: always() + shell: bash + run: | + { + echo "### sccache stats — ${{ matrix.target }} (tests)"; + echo; + echo '```'; + sccache --show-stats || true; + echo '```'; + } >> "$GITHUB_STEP_SUMMARY" + + - name: verify tests passed + if: steps.test.outcome == 'failure' + run: | + echo "Tests failed. See logs for details." exit 1 # --- Gatherer job that you mark as the ONLY required status ----------------- results: name: CI results (required) - needs: [changed, general, cargo_shear, lint_build_test] + needs: [changed, general, cargo_shear, lint_build, tests] if: always() runs-on: ubuntu-24.04 steps: @@ -252,7 +466,8 @@ jobs: run: | echo "general: ${{ needs.general.result }}" echo "shear : ${{ needs.cargo_shear.result }}" - echo "matrix : ${{ needs.lint_build_test.result }}" + echo "lint : ${{ needs.lint_build.result }}" + echo "tests : ${{ needs.tests.result }}" # If nothing relevant changed (PR touching only root README, etc.), # declare success regardless of other jobs. @@ -264,4 +479,10 @@ jobs: # Otherwise require the jobs to have succeeded [[ '${{ needs.general.result }}' == 'success' ]] || { echo 'general failed'; exit 1; } [[ '${{ needs.cargo_shear.result }}' == 'success' ]] || { echo 'cargo_shear failed'; exit 1; } - [[ '${{ needs.lint_build_test.result }}' == 'success' ]] || { echo 'matrix failed'; exit 1; } + [[ '${{ needs.lint_build.result }}' == 'success' ]] || { echo 'lint_build failed'; exit 1; } + [[ '${{ needs.tests.result }}' == 'success' ]] || { echo 'tests failed'; exit 1; } + + - name: sccache summary note + if: always() + run: | + echo "Per-job sccache stats are attached to each matrix job's Step Summary." diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 7654e696cf..26afdaf552 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -10,16 +10,6 @@ on: push: tags: - "rust-v*.*.*" - workflow_dispatch: - inputs: - branch: - description: "Branch or tag to build" - required: true - default: "main" - dry_run: - description: "Skip publishing the release (dry run)" - type: boolean - default: false concurrency: group: ${{ github.workflow }} @@ -30,11 +20,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - with: - ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || github.ref }} - name: Validate tag matches Cargo.toml version - if: ${{ github.event_name == 'push' }} shell: bash run: | set -euo pipefail @@ -58,10 +45,6 @@ jobs: echo "✅ Tag and Cargo.toml agree (${tag_ver})" echo "::endgroup::" - - name: Skip tag validation (manual run) - if: ${{ github.event_name != 'push' }} - run: echo "Tag validation skipped for workflow_dispatch runs." - build: needs: tag-check name: Build - ${{ matrix.runner }} - ${{ matrix.target }} @@ -75,9 +58,9 @@ jobs: fail-fast: false matrix: include: - - runner: macos-14 + - runner: macos-15-xlarge target: aarch64-apple-darwin - - runner: macos-14 + - runner: macos-15-xlarge target: x86_64-apple-darwin - runner: ubuntu-24.04 target: x86_64-unknown-linux-musl @@ -94,8 +77,6 @@ jobs: steps: - uses: actions/checkout@v5 - with: - ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.branch || github.ref }} - uses: dtolnay/rust-toolchain@1.90 with: targets: ${{ matrix.target }} @@ -119,7 +100,7 @@ jobs: - name: Cargo build run: cargo build --target ${{ matrix.target }} --release --bin codex --bin codex-responses-api-proxy - - if: ${{ matrix.runner == 'macos-14' }} + - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Configure Apple code signing shell: bash env: @@ -204,7 +185,7 @@ jobs: echo "APPLE_CODESIGN_KEYCHAIN=$keychain_path" >> "$GITHUB_ENV" echo "::add-mask::$APPLE_CODESIGN_IDENTITY" - - if: ${{ matrix.runner == 'macos-14' }} + - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Sign macOS binaries shell: bash run: | @@ -225,7 +206,7 @@ jobs: codesign --force --options runtime --timestamp --sign "$APPLE_CODESIGN_IDENTITY" "${keychain_args[@]}" "$path" done - - if: ${{ matrix.runner == 'macos-14' }} + - if: ${{ matrix.runner == 'macos-15-xlarge' }} name: Notarize macOS binaries shell: bash env: @@ -347,7 +328,7 @@ jobs: done - name: Remove signing keychain - if: ${{ always() && matrix.runner == 'macos-14' }} + if: ${{ always() && matrix.runner == 'macos-15-xlarge' }} shell: bash env: APPLE_CODESIGN_KEYCHAIN: ${{ env.APPLE_CODESIGN_KEYCHAIN }} @@ -369,7 +350,7 @@ jobs: fi fi - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v5 with: name: ${{ matrix.target }} # Upload the per-binary .zst files as well as the new .tar.gz @@ -381,7 +362,6 @@ jobs: needs: build name: release runs-on: ubuntu-latest - if: ${{ github.event_name == 'push' && startsWith(github.ref, 'refs/tags/rust-v') }} permissions: contents: write actions: read @@ -476,7 +456,7 @@ jobs: # npm docs: https://docs.npmjs.com/trusted-publishers publish-npm: # Publish to npm for stable releases and alpha pre-releases with numeric suffixes. - if: ${{ needs.release.result == 'success' && needs.release.outputs.should_publish_npm == 'true' }} + if: ${{ needs.release.outputs.should_publish_npm == 'true' }} name: publish-npm needs: release runs-on: ubuntu-latest @@ -545,7 +525,6 @@ jobs: contents: write needs: release runs-on: ubuntu-latest - if: ${{ needs.release.result == 'success' }} steps: - name: Update latest-alpha-cli branch diff --git a/.vscode/settings.json b/.vscode/settings.json index aadeca0867..5adec04a83 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,6 +3,7 @@ "rust-analyzer.check.command": "clippy", "rust-analyzer.check.extraArgs": ["--all-features", "--tests"], "rust-analyzer.rustfmt.extraArgs": ["--config", "imports_granularity=Item"], + "rust-analyzer.cargo.targetDir": "${workspaceFolder}/codex-rs/target/rust-analyzer", "[rust]": { "editor.defaultFormatter": "rust-lang.rust-analyzer", "editor.formatOnSave": true, diff --git a/AGENTS.md b/AGENTS.md index 832f1d6543..7960ebba9b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,9 @@ In the codex-rs folder where the rust code lives: - Always collapse if statements per https://rust-lang.github.io/rust-clippy/master/index.html#collapsible_if - Always inline format! args when possible per https://rust-lang.github.io/rust-clippy/master/index.html#uninlined_format_args - Use method references over closures when possible per https://rust-lang.github.io/rust-clippy/master/index.html#redundant_closure_for_method_calls +- Do not use unsigned integer even if the number cannot be negative. - When writing tests, prefer comparing the equality of entire objects over fields one by one. +- When making a change that adds or changes an API, ensure that the documentation in the `docs/` folder is up to date if applicable. Run `just fmt` (in `codex-rs` directory) automatically after making Rust code changes; do not ask for approval to run it. Before finalizing a change to `codex-rs`, run `just fix -p ` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspace‑wide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Additionally, run the tests: @@ -82,6 +84,7 @@ If you don’t have the tool: - Use `ResponseMock::single_request()` when a test should only issue one POST, or `ResponseMock::requests()` to inspect every captured `ResponsesRequest`. - `ResponsesRequest` exposes helpers (`body_json`, `input`, `function_call_output`, `custom_tool_call_output`, `call_output`, `header`, `path`, `query_param`) so assertions can target structured payloads instead of manual JSON digging. - Build SSE payloads with the provided `ev_*` constructors and the `sse(...)`. +- Prefer `wait_for_event` over `wait_for_event_with_timeout`. - Typical pattern: diff --git a/CHANGELOG.md b/CHANGELOG.md index ed8bb45518..2eb564c560 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1 +1 @@ -The changelog can be found on the [releases page](https://github.com/openai/codex/releases) +The changelog can be found on the [releases page](https://github.com/openai/codex/releases). diff --git a/README.md b/README.md index c9cc4e1fd4..8141610035 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -

npm i -g @openai/codex
or brew install codex

+

npm i -g @openai/codex
or brew install --cask codex

Codex CLI is a coding agent from OpenAI that runs locally on your computer.
@@ -24,7 +24,7 @@ npm install -g @openai/codex Alternatively, if you use Homebrew: ```shell -brew install codex +brew install --cask codex ``` Then simply run `codex` to get started: @@ -33,6 +33,8 @@ Then simply run `codex` to get started: codex ``` +If you're running into upgrade issues with Homebrew, see the [FAQ entry on brew upgrade codex](./docs/faq.md#brew-upgrade-codex-isnt-upgrading-me). +

You can also go to the latest GitHub Release and download the appropriate binary for your platform. @@ -73,10 +75,13 @@ Codex CLI supports a rich set of configuration options, with preferences stored - [**Getting started**](./docs/getting-started.md) - [CLI usage](./docs/getting-started.md#cli-usage) + - [Slash Commands](./docs/slash_commands.md) - [Running with a prompt as input](./docs/getting-started.md#running-with-a-prompt-as-input) - [Example prompts](./docs/getting-started.md#example-prompts) + - [Custom prompts](./docs/prompts.md) - [Memory with AGENTS.md](./docs/getting-started.md#memory-with-agentsmd) - - [Configuration](./docs/config.md) +- [**Configuration**](./docs/config.md) + - [Example config](./docs/example-config.md) - [**Sandbox & approvals**](./docs/sandbox.md) - [**Authentication**](./docs/authentication.md) - [Auth methods](./docs/authentication.md#forcing-a-specific-auth-method-advanced) diff --git a/cliff.toml b/cliff.toml index 30aa1f1ac6..f31e1bd89c 100644 --- a/cliff.toml +++ b/cliff.toml @@ -4,7 +4,7 @@ header = """ # Changelog -You can install any of these versions: `npm install -g codex@version` +You can install any of these versions: `npm install -g @openai/codex@` """ body = """ diff --git a/codex-rs/.cargo/config.toml b/codex-rs/.cargo/config.toml new file mode 100644 index 0000000000..f5107cf73e --- /dev/null +++ b/codex-rs/.cargo/config.toml @@ -0,0 +1,5 @@ +[target.'cfg(all(windows, target_env = "msvc"))'] +rustflags = ["-C", "link-arg=/STACK:8388608"] + +[target.'cfg(all(windows, target_env = "gnu"))'] +rustflags = ["-C", "link-arg=-Wl,--stack,8388608"] diff --git a/codex-rs/.gitignore b/codex-rs/.gitignore index e996253768..e31566047d 100644 --- a/codex-rs/.gitignore +++ b/codex-rs/.gitignore @@ -1,4 +1,5 @@ /target/ +/target-*/ # Recommended value of CARGO_TARGET_DIR when using Docker as explained in .devcontainer/README.md. /target-amd64/ diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 5576b7cba9..a7f73e9c91 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -172,9 +172,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.99" +version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "app_test_support" @@ -182,10 +182,15 @@ version = "0.0.0" dependencies = [ "anyhow", "assert_cmd", + "base64", + "chrono", "codex-app-server-protocol", + "codex-core", + "codex-protocol", "serde", "serde_json", "tokio", + "uuid", "wiremock", ] @@ -589,9 +594,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "block-buffer" @@ -834,19 +839,24 @@ dependencies = [ "app_test_support", "assert_cmd", "base64", + "chrono", "codex-app-server-protocol", "codex-arg0", + "codex-backend-client", "codex-common", "codex-core", + "codex-feedback", "codex-file-search", "codex-login", "codex-protocol", "codex-utils-json-to-toml", "core_test_support", + "opentelemetry-appender-tracing", "os_info", "pretty_assertions", "serde", "serde_json", + "serial_test", "tempfile", "tokio", "toml", @@ -861,9 +871,12 @@ name = "codex-app-server-protocol" version = "0.0.0" dependencies = [ "anyhow", + "clap", "codex-protocol", + "mcp-types", "paste", "pretty_assertions", + "schemars 0.8.22", "serde", "serde_json", "strum_macros 0.27.2", @@ -881,7 +894,7 @@ dependencies = [ "pretty_assertions", "similar", "tempfile", - "thiserror 2.0.16", + "thiserror 2.0.17", "tree-sitter", "tree-sitter-bash", ] @@ -899,12 +912,24 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-async-utils" +version = "0.0.0" +dependencies = [ + "async-trait", + "pretty_assertions", + "tokio", + "tokio-util", +] + [[package]] name = "codex-backend-client" version = "0.0.0" dependencies = [ "anyhow", "codex-backend-openapi-models", + "codex-core", + "codex-protocol", "pretty_assertions", "reqwest", "serde", @@ -917,6 +942,7 @@ version = "0.0.0" dependencies = [ "serde", "serde_json", + "serde_with", ] [[package]] @@ -927,7 +953,7 @@ dependencies = [ "clap", "codex-common", "codex-core", - "codex-git-apply", + "codex-git", "serde", "serde_json", "tempfile", @@ -958,7 +984,9 @@ dependencies = [ "codex-protocol-ts", "codex-responses-api-proxy", "codex-rmcp-client", + "codex-stdio-to-uds", "codex-tui", + "codex-windows-sandbox", "ctor 0.5.0", "owo-colors", "predicates", @@ -967,6 +995,7 @@ dependencies = [ "supports-color", "tempfile", "tokio", + "toml", ] [[package]] @@ -1003,11 +1032,11 @@ dependencies = [ "async-trait", "chrono", "codex-backend-client", - "codex-git-apply", + "codex-git", "diffy", "serde", "serde_json", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -1037,12 +1066,18 @@ dependencies = [ "chrono", "codex-app-server-protocol", "codex-apply-patch", + "codex-async-utils", "codex-file-search", - "codex-mcp-client", + "codex-git", + "codex-keyring-store", "codex-otel", "codex-protocol", "codex-rmcp-client", + "codex-utils-pty", + "codex-utils-readiness", "codex-utils-string", + "codex-utils-tokenizer", + "codex-windows-sandbox", "core-foundation 0.9.4", "core_test_support", "dirs", @@ -1051,14 +1086,16 @@ dependencies = [ "escargot", "eventsource-stream", "futures", - "indexmap 2.10.0", + "http", + "image", + "indexmap 2.12.0", + "keyring", "landlock", "libc", "maplit", "mcp-types", "openssl-sys", "os_info", - "portable-pty", "predicates", "pretty_assertions", "rand 0.9.2", @@ -1069,11 +1106,13 @@ dependencies = [ "serde_json", "serial_test", "sha1", + "sha2", "shlex", "similar", "strum_macros 0.27.2", "tempfile", - "thiserror 2.0.16", + "test-log", + "thiserror 2.0.17", "time", "tokio", "tokio-test", @@ -1144,6 +1183,17 @@ dependencies = [ "tempfile", ] +[[package]] +name = "codex-feedback" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-protocol", + "pretty_assertions", + "sentry", + "tracing-subscriber", +] + [[package]] name = "codex-file-search" version = "0.0.0" @@ -1158,25 +1208,29 @@ dependencies = [ ] [[package]] -name = "codex-git-apply" -version = "0.0.0" -dependencies = [ - "once_cell", - "regex", - "tempfile", -] - -[[package]] -name = "codex-git-tooling" +name = "codex-git" version = "0.0.0" dependencies = [ "assert_matches", + "once_cell", "pretty_assertions", + "regex", + "schemars 0.8.22", + "serde", "tempfile", - "thiserror 2.0.16", + "thiserror 2.0.17", + "ts-rs", "walkdir", ] +[[package]] +name = "codex-keyring-store" +version = "0.0.0" +dependencies = [ + "keyring", + "tracing", +] + [[package]] name = "codex-linux-sandbox" version = "0.0.0" @@ -1214,19 +1268,6 @@ dependencies = [ "wiremock", ] -[[package]] -name = "codex-mcp-client" -version = "0.0.0" -dependencies = [ - "anyhow", - "mcp-types", - "serde", - "serde_json", - "tokio", - "tracing", - "tracing-subscriber", -] - [[package]] name = "codex-mcp-server" version = "0.0.0" @@ -1304,10 +1345,14 @@ version = "0.0.0" dependencies = [ "anyhow", "base64", + "codex-git", + "codex-utils-image", "icu_decimal", "icu_locale_core", + "icu_provider", "mcp-types", "mime_guess", + "schemars 0.8.22", "serde", "serde_json", "serde_with", @@ -1352,8 +1397,10 @@ version = "0.0.0" dependencies = [ "anyhow", "axum", + "codex-keyring-store", "codex-protocol", "dirs", + "escargot", "futures", "keyring", "mcp-types", @@ -1363,6 +1410,7 @@ dependencies = [ "rmcp", "serde", "serde_json", + "serial_test", "sha2", "tempfile", "tiny_http", @@ -1372,6 +1420,17 @@ dependencies = [ "webbrowser", ] +[[package]] +name = "codex-stdio-to-uds" +version = "0.0.0" +dependencies = [ + "anyhow", + "assert_cmd", + "pretty_assertions", + "tempfile", + "uds_windows", +] + [[package]] name = "codex-tui" version = "0.0.0" @@ -1388,13 +1447,15 @@ dependencies = [ "codex-arg0", "codex-common", "codex-core", + "codex-feedback", "codex-file-search", - "codex-git-tooling", "codex-login", "codex-ollama", "codex-protocol", + "codex-windows-sandbox", "color-eyre", "crossterm", + "derive_more 2.0.1", "diffy", "dirs", "dunce", @@ -1405,15 +1466,16 @@ dependencies = [ "libc", "mcp-types", "opentelemetry-appender-tracing", - "path-clean", "pathdiff", "pretty_assertions", "pulldown-cmark", "rand 0.9.2", "ratatui", + "ratatui-macros", "regex-lite", "serde", "serde_json", + "serial_test", "shlex", "strum 0.27.2", "strum_macros 0.27.2", @@ -1422,6 +1484,7 @@ dependencies = [ "textwrap 0.16.2", "tokio", "tokio-stream", + "toml", "tracing", "tracing-appender", "tracing-subscriber", @@ -1433,6 +1496,27 @@ dependencies = [ "vt100", ] +[[package]] +name = "codex-utils-cache" +version = "0.0.0" +dependencies = [ + "lru", + "sha1", + "tokio", +] + +[[package]] +name = "codex-utils-image" +version = "0.0.0" +dependencies = [ + "base64", + "codex-utils-cache", + "image", + "tempfile", + "thiserror 2.0.17", + "tokio", +] + [[package]] name = "codex-utils-json-to-toml" version = "0.0.0" @@ -1442,13 +1526,22 @@ dependencies = [ "toml", ] +[[package]] +name = "codex-utils-pty" +version = "0.0.0" +dependencies = [ + "anyhow", + "portable-pty", + "tokio", +] + [[package]] name = "codex-utils-readiness" version = "0.0.0" dependencies = [ "assert_matches", "async-trait", - "thiserror 2.0.16", + "thiserror 2.0.17", "time", "tokio", ] @@ -1457,6 +1550,28 @@ dependencies = [ name = "codex-utils-string" version = "0.0.0" +[[package]] +name = "codex-utils-tokenizer" +version = "0.0.0" +dependencies = [ + "anyhow", + "pretty_assertions", + "thiserror 2.0.17", + "tiktoken-rs", +] + +[[package]] +name = "codex-windows-sandbox" +version = "0.1.0" +dependencies = [ + "anyhow", + "dirs-next", + "rand 0.8.5", + "serde", + "serde_json", + "windows-sys 0.52.0", +] + [[package]] name = "color-eyre" version = "0.6.5" @@ -1544,6 +1659,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1577,6 +1701,7 @@ dependencies = [ "anyhow", "assert_cmd", "codex-core", + "codex-protocol", "notify", "regex-lite", "serde_json", @@ -1641,10 +1766,9 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crossterm" version = "0.28.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +source = "git+https://github.com/nornagon/crossterm?branch=nornagon%2Fcolor-query#87db8bfa6dc99427fd3b071681b07fc31c6ce995" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "crossterm_winapi", "futures-core", "mio", @@ -1823,6 +1947,16 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid", +] + [[package]] name = "debugserver-types" version = "0.5.0" @@ -1879,7 +2013,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ - "convert_case", + "convert_case 0.6.0", "proc-macro2", "quote", "syn 2.0.104", @@ -1892,6 +2026,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" dependencies = [ + "convert_case 0.7.1", "proc-macro2", "quote", "syn 2.0.104", @@ -1978,7 +2113,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "objc2", ] @@ -2244,6 +2379,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fancy-regex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax 0.8.5", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -2301,6 +2447,18 @@ dependencies = [ "winapi", ] +[[package]] +name = "findshlibs" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64" +dependencies = [ + "cc", + "lazy_static", + "libc", + "winapi", +] + [[package]] name = "fixed_decimal" version = "0.7.0" @@ -2580,7 +2738,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.10.0", + "indexmap 2.12.0", "slab", "tokio", "tokio-util", @@ -2624,6 +2782,12 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + [[package]] name = "heck" version = "0.5.0" @@ -2669,6 +2833,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "hostname" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a56f203cd1c76362b69e3863fd987520ac36cf70a8c92627449b2f64a8cf7d65" +dependencies = [ + "cfg-if", + "libc", + "windows-link 0.1.3", +] + [[package]] name = "http" version = "1.3.1" @@ -2846,9 +3021,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", @@ -2859,34 +3034,31 @@ dependencies = [ [[package]] name = "icu_decimal" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fec61c43fdc4e368a9f450272833123a8ef0d7083a44597660ce94d791b8a2e2" +checksum = "a38c52231bc348f9b982c1868a2af3195199623007ba2c7650f432038f5b3e8e" dependencies = [ - "displaydoc", "fixed_decimal", "icu_decimal_data", "icu_locale", "icu_locale_core", "icu_provider", - "tinystr", "writeable", "zerovec", ] [[package]] name = "icu_decimal_data" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b70963bc35f9bdf1bc66a5c1f458f4991c1dc71760e00fa06016b2c76b2738d5" +checksum = "2905b4044eab2dd848fe84199f9195567b63ab3a93094711501363f63546fef7" [[package]] name = "icu_locale" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ae5921528335e91da1b6c695dbf1ec37df5ac13faa3f91e5640be93aa2fbefd" +checksum = "532b11722e350ab6bf916ba6eb0efe3ee54b932666afec989465f9243fe6dd60" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_locale_data", @@ -2898,12 +3070,13 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", @@ -2911,17 +3084,16 @@ dependencies = [ [[package]] name = "icu_locale_data" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fdef0c124749d06a743c69e938350816554eb63ac979166590e2b4ee4252765" +checksum = "f03e2fcaefecdf05619f3d6f91740e79ab969b4dd54f77cbf546b1d0d28e3147" [[package]] name = "icu_normalizer" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", @@ -2932,42 +3104,40 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", - "potential_utf", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" [[package]] name = "icu_provider" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", + "serde", "stable_deref_trait", - "tinystr", "writeable", "yoke", "zerofrom", @@ -3053,13 +3223,14 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.10.0" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" dependencies = [ "equivalent", - "hashbrown 0.15.4", + "hashbrown 0.16.0", "serde", + "serde_core", ] [[package]] @@ -3074,7 +3245,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f37dccff2791ab604f9babef0ba14fbe0be30bd368dc541e2b08d07c8aa908f3" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "inotify-sys", "libc", ] @@ -3137,7 +3308,7 @@ version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "cfg-if", "libc", ] @@ -3347,7 +3518,7 @@ checksum = "b3d2ef408b88e913bfc6594f5e693d57676f6463ded7d8bf994175364320c706" dependencies = [ "enumflags2", "libc", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -3383,7 +3554,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "libc", ] @@ -3393,7 +3564,7 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "761e49ec5fd8a5a463f9b84e877c373d888935b71c6be78f3767fe2ae6bed18e" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "libc", ] @@ -3507,6 +3678,7 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" name = "mcp-types" version = "0.0.0" dependencies = [ + "schemars 0.8.22", "serde", "serde_json", "ts-rs", @@ -3660,7 +3832,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "cfg-if", "cfg_aliases 0.1.1", "libc", @@ -3672,7 +3844,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "cfg-if", "cfg_aliases 0.2.1", "libc", @@ -3685,7 +3857,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "cfg-if", "cfg_aliases 0.2.1", "libc", @@ -3713,7 +3885,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "fsevent-sys", "inotify", "kqueue", @@ -3883,7 +4055,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6f29f568bec459b0ddff777cec4fe3fd8666d82d5a40ebd0ff7e66134f89bcc" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "objc2", "objc2-core-graphics", "objc2-foundation", @@ -3895,7 +4067,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c10c2894a6fed806ade6027bcd50662746363a9589d3ec9d9bef30a4e4bc166" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "dispatch2", "objc2", ] @@ -3906,7 +4078,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989c6c68c13021b5c2d6b71456ebb0f9dc78d752e86a98da7c716f4f9470f5a4" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "dispatch2", "objc2", "objc2-core-foundation", @@ -3925,7 +4097,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "900831247d2fe1a09a683278e5384cfb8c80c79fe6b166f9d14bfdde0ea1b03c" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "objc2", "objc2-core-foundation", ] @@ -3936,7 +4108,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7282e9ac92529fa3457ce90ebb15f4ecbc383e8338060960760fa2cf75420c3c" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "objc2", "objc2-core-foundation", ] @@ -3968,7 +4140,7 @@ version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "cfg-if", "foreign-types", "libc", @@ -4026,7 +4198,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.16", + "thiserror 2.0.17", "tracing", ] @@ -4069,7 +4241,7 @@ dependencies = [ "prost", "reqwest", "serde_json", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tonic", "tracing", @@ -4109,7 +4281,7 @@ dependencies = [ "percent-encoding", "rand 0.9.2", "serde_json", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tokio-stream", ] @@ -4192,12 +4364,6 @@ dependencies = [ "path-dedot", ] -[[package]] -name = "path-clean" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17359afc20d7ab31fdb42bb844c8b3bb1dabd7dcf7e68428492da7f16966fcef" - [[package]] name = "path-dedot" version = "3.1.1" @@ -4226,7 +4392,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset", - "indexmap 2.10.0", + "indexmap 2.12.0", ] [[package]] @@ -4294,7 +4460,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1" dependencies = [ "base64", - "indexmap 2.10.0", + "indexmap 2.12.0", "quick-xml", "serde", "time", @@ -4306,7 +4472,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "crc32fast", "fdeflate", "flate2", @@ -4365,11 +4531,12 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.2" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ - "serde", + "serde_core", + "writeable", "zerovec", ] @@ -4459,7 +4626,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3ef4f2f0422f23a82ec9f628ea2acd12871c81a9362b02c43c1aa86acfc3ba1" dependencies = [ "futures", - "indexmap 2.10.0", + "indexmap 2.12.0", "nix 0.30.1", "tokio", "tracing", @@ -4495,7 +4662,7 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76979bea66e7875e7509c4ec5300112b316af87fa7a252ca91c448b32dfe3993" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "getopts", "memchr", "pulldown-cmark-escape", @@ -4543,10 +4710,10 @@ dependencies = [ "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.1", "rustls", "socket2 0.6.0", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tracing", "web-time", @@ -4563,11 +4730,11 @@ dependencies = [ "lru-slab", "rand 0.9.2", "ring", - "rustc-hash", + "rustc-hash 2.1.1", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.16", + "thiserror 2.0.17", "tinyvec", "tracing", "web-time", @@ -4676,7 +4843,7 @@ name = "ratatui" version = "0.29.0" source = "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#9b2ad1298408c45918ee9f8241a6f95498cdbed2" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "cassowary", "compact_str", "crossterm", @@ -4691,13 +4858,22 @@ dependencies = [ "unicode-width 0.2.1", ] +[[package]] +name = "ratatui-macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fef540f80dbe8a0773266fa6077788ceb65ef624cdbf36e131aaf90b4a52df4" +dependencies = [ + "ratatui", +] + [[package]] name = "redox_syscall" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e8af0dde094006011e6a740d4879319439489813bd0bcdc7d821beaeeff48ec" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", ] [[package]] @@ -4719,7 +4895,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -4848,9 +5024,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "0.8.1" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f35acda8f89fca5fd8c96cae3c6d5b4c38ea0072df4c8030915f3b5ff469c1c" +checksum = "e5947688160b56fb6c827e3c20a72c90392a1d7e9dec74749197aa1780ac42ca" dependencies = [ "base64", "bytes", @@ -4870,7 +5046,7 @@ dependencies = [ "serde", "serde_json", "sse-stream", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tokio-stream", "tokio-util", @@ -4882,9 +5058,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "0.8.1" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9f1d5220aaa23b79c3d02e18f7a554403b3ccea544bbb6c69d6bcb3e854a274" +checksum = "01263441d3f8635c628e33856c468b96ebbce1af2d3699ea712ca71432d4ee7a" dependencies = [ "darling 0.21.3", "proc-macro2", @@ -4899,19 +5075,34 @@ version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f" +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -4924,7 +5115,7 @@ version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys 0.9.4", @@ -4990,7 +5181,7 @@ version = "14.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7803e8936da37efd9b6d4478277f4b2b9bb5cdb37a113e8d63222e58da647e63" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "cfg-if", "clipboard-win", "fd-lock", @@ -5189,7 +5380,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -5202,7 +5393,7 @@ version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -5220,10 +5411,124 @@ dependencies = [ ] [[package]] -name = "serde" -version = "1.0.226" +name = "semver" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "sentry" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5484316556650182f03b43d4c746ce0e3e48074a21e2f51244b648b6542e1066" +dependencies = [ + "httpdate", + "native-tls", + "reqwest", + "sentry-backtrace", + "sentry-contexts", + "sentry-core", + "sentry-debug-images", + "sentry-panic", + "sentry-tracing", + "tokio", + "ureq", +] + +[[package]] +name = "sentry-backtrace" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40aa225bb41e2ec9d7c90886834367f560efc1af028f1c5478a6cce6a59c463a" +dependencies = [ + "backtrace", + "once_cell", + "regex", + "sentry-core", +] + +[[package]] +name = "sentry-contexts" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8dd746da3d16cb8c39751619cefd4fcdbd6df9610f3310fd646b55f6e39910" +dependencies = [ + "hostname", + "libc", + "os_info", + "rustc_version", + "sentry-core", + "uname", +] + +[[package]] +name = "sentry-core" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "161283cfe8e99c8f6f236a402b9ccf726b201f365988b5bb637ebca0abbd4a30" +dependencies = [ + "once_cell", + "rand 0.8.5", + "sentry-types", + "serde", + "serde_json", +] + +[[package]] +name = "sentry-debug-images" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc6b25e945fcaa5e97c43faee0267eebda9f18d4b09a251775d8fef1086238a" +dependencies = [ + "findshlibs", + "once_cell", + "sentry-core", +] + +[[package]] +name = "sentry-panic" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74f229c7186dd971a9491ffcbe7883544aa064d1589bd30b83fb856cd22d63" +dependencies = [ + "sentry-backtrace", + "sentry-core", +] + +[[package]] +name = "sentry-tracing" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3c5faf2103cd01eeda779ea439b68c4ee15adcdb16600836e97feafab362ec" +dependencies = [ + "sentry-backtrace", + "sentry-core", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "sentry-types" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d68cdf6bc41b8ff3ae2a9c4671e97426dcdd154cc1d4b6b72813f285d6b163f" +dependencies = [ + "debugid", + "hex", + "rand 0.8.5", + "serde", + "serde_json", + "thiserror 1.0.69", + "time", + "url", + "uuid", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -5231,18 +5536,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -5266,7 +5571,7 @@ version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ - "indexmap 2.10.0", + "indexmap 2.12.0", "itoa", "memchr", "ryu", @@ -5327,7 +5632,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.10.0", + "indexmap 2.12.0", "schemars 0.9.0", "schemars 1.0.4", "serde", @@ -5396,6 +5701,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + [[package]] name = "sha2" version = "0.10.9" @@ -5787,7 +6098,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -5851,6 +6162,28 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "test-log" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e33b98a582ea0be1168eba097538ee8dd4bbe0f2b01b22ac92ea30054e5be7b" +dependencies = [ + "env_logger", + "test-log-macros", + "tracing-subscriber", +] + +[[package]] +name = "test-log-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451b374529930d7601b1eef8d32bc79ae870b6079b069401709c2a8bf9e75f36" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "textwrap" version = "0.11.0" @@ -5882,11 +6215,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.16" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.16", + "thiserror-impl 2.0.17", ] [[package]] @@ -5902,9 +6235,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.16" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", @@ -5934,6 +6267,21 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "tiktoken-rs" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25563eeba904d770acf527e8b370fe9a5547bacd20ff84a0b6c3bc41288e5625" +dependencies = [ + "anyhow", + "base64", + "bstr", + "fancy-regex", + "lazy_static", + "regex", + "rustc-hash 1.1.0", +] + [[package]] name = "time" version = "0.3.44" @@ -6108,7 +6456,7 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" dependencies = [ - "indexmap 2.10.0", + "indexmap 2.12.0", "serde", "serde_spanned", "toml_datetime", @@ -6132,7 +6480,7 @@ version = "0.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7211ff1b8f0d3adae1663b7da9ffe396eabe1ca25f0b0bee42b0da29a9ddce93" dependencies = [ - "indexmap 2.10.0", + "indexmap 2.12.0", "toml_datetime", "toml_parser", "toml_writer", @@ -6191,7 +6539,7 @@ checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" dependencies = [ "futures-core", "futures-util", - "indexmap 2.10.0", + "indexmap 2.12.0", "pin-project-lite", "slab", "sync_wrapper", @@ -6208,7 +6556,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", "bytes", "futures-util", "http", @@ -6369,7 +6717,7 @@ checksum = "adc5f880ad8d8f94e88cb81c3557024cf1a8b75e3b504c50481ed4f5a6006ff3" dependencies = [ "regex", "streaming-iterator", - "thiserror 2.0.16", + "thiserror 2.0.17", "tree-sitter", ] @@ -6392,7 +6740,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ef1b7a6d914a34127ed8e1fa927eb7088903787bcded4fa3eef8f85ee1568be" dependencies = [ "serde_json", - "thiserror 2.0.16", + "thiserror 2.0.17", "ts-rs-macros", "uuid", ] @@ -6426,6 +6774,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + [[package]] name = "unicase" version = "2.8.1" @@ -6485,6 +6842,19 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" +dependencies = [ + "base64", + "log", + "native-tls", + "once_cell", + "url", +] + [[package]] name = "url" version = "2.5.4" @@ -6524,6 +6894,7 @@ dependencies = [ "getrandom 0.3.3", "js-sys", "serde", + "sha1_smol", "wasm-bindgen", ] @@ -7261,14 +7632,14 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.10.0", ] [[package]] name = "writeable" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "x11rb" @@ -7463,10 +7834,11 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.2" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 1c1640be3f..619dbb5658 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -2,10 +2,12 @@ members = [ "backend-client", "ansi-escape", + "async-utils", "app-server", "app-server-protocol", "apply-patch", "arg0", + "feedback", "codex-backend-openapi-models", "cloud-tasks", "cloud-tasks-client", @@ -14,11 +16,10 @@ members = [ "core", "exec", "execpolicy", + "keyring-store", "file-search", - "git-tooling", "linux-sandbox", "login", - "mcp-client", "mcp-server", "mcp-types", "ollama", @@ -27,12 +28,17 @@ members = [ "protocol-ts", "rmcp-client", "responses-api-proxy", + "stdio-to-uds", "otel", "tui", - "git-apply", + "utils/git", + "utils/cache", + "utils/image", "utils/json-to-toml", + "utils/pty", "utils/readiness", "utils/string", + "utils/tokenizer", ] resolver = "2" @@ -52,15 +58,18 @@ codex-app-server = { path = "app-server" } codex-app-server-protocol = { path = "app-server-protocol" } codex-apply-patch = { path = "apply-patch" } codex-arg0 = { path = "arg0" } +codex-async-utils = { path = "async-utils" } +codex-backend-client = { path = "backend-client" } codex-chatgpt = { path = "chatgpt" } codex-common = { path = "common" } codex-core = { path = "core" } codex-exec = { path = "exec" } +codex-feedback = { path = "feedback" } codex-file-search = { path = "file-search" } -codex-git-tooling = { path = "git-tooling" } +codex-git = { path = "utils/git" } +codex-keyring-store = { path = "keyring-store" } codex-linux-sandbox = { path = "linux-sandbox" } codex-login = { path = "login" } -codex-mcp-client = { path = "mcp-client" } codex-mcp-server = { path = "mcp-server" } codex-ollama = { path = "ollama" } codex-otel = { path = "otel" } @@ -69,10 +78,16 @@ codex-protocol = { path = "protocol" } codex-protocol-ts = { path = "protocol-ts" } codex-responses-api-proxy = { path = "responses-api-proxy" } codex-rmcp-client = { path = "rmcp-client" } +codex-stdio-to-uds = { path = "stdio-to-uds" } codex-tui = { path = "tui" } +codex-utils-cache = { path = "utils/cache" } +codex-utils-image = { path = "utils/image" } codex-utils-json-to-toml = { path = "utils/json-to-toml" } +codex-utils-pty = { path = "utils/pty" } codex-utils-readiness = { path = "utils/readiness" } codex-utils-string = { path = "utils/string" } +codex-utils-tokenizer = { path = "utils/tokenizer" } +codex-windows-sandbox = { path = "windows-sandbox-rs" } core_test_support = { path = "core/tests/common" } mcp-types = { path = "mcp-types" } mcp_test_support = { path = "mcp-server/tests/common" } @@ -83,8 +98,8 @@ ansi-to-tui = "7.0.0" anyhow = "1" arboard = "3" askama = "0.12" -assert_matches = "1.5.0" assert_cmd = "2" +assert_matches = "1.5.0" async-channel = "2.3.1" async-stream = "0.3.6" async-trait = "0.1.89" @@ -107,11 +122,13 @@ env_logger = "0.11.5" escargot = "0.5" eventsource-stream = "0.2.3" futures = { version = "0.3", default-features = false } -icu_decimal = "2.0.0" -icu_locale_core = "2.0.0" +http = "1.3.1" +icu_decimal = "2.1" +icu_provider = { version = "2.1", features = ["sync"] } +icu_locale_core = "2.1" ignore = "0.4.23" image = { version = "^0.25.8", default-features = false } -indexmap = "2.6.0" +indexmap = "2.12.0" insta = "1.43.2" itertools = "0.14.0" keyring = "3.6" @@ -119,6 +136,7 @@ landlock = "0.4.1" lazy_static = "1" libc = "0.2.175" log = "0.4" +lru = "0.12.5" maplit = "1.0.2" mime_guess = "2.0.5" multimap = "0.10.0" @@ -134,7 +152,6 @@ os_info = "3.12.0" owo-colors = "4.2.0" paste = "1.0.15" path-absolutize = "3.1.1" -path-clean = "1.0.1" pathdiff = "0.2" portable-pty = "0.9.0" predicates = "3" @@ -142,11 +159,13 @@ pretty_assertions = "1.4.1" pulldown-cmark = "0.10" rand = "0.9" ratatui = "0.29.0" +ratatui-macros = "0.6.0" regex-lite = "0.1.7" reqwest = "0.12" -rmcp = { version = "0.8.0", default-features = false } +rmcp = { version = "0.8.5", default-features = false } schemars = "0.8.22" seccompiler = "0.5.0" +sentry = "0.34.0" serde = "1" serde_json = "1" serde_with = "3.14" @@ -161,8 +180,9 @@ strum_macros = "0.27.2" supports-color = "3.0.2" sys-locale = "0.3.2" tempfile = "3.23.0" +test-log = "0.2.18" textwrap = "0.16.2" -thiserror = "2.0.16" +thiserror = "2.0.17" time = "0.3" tiny_http = "0.12" tokio = "1" @@ -180,6 +200,7 @@ tree-sitter = "0.25.10" tree-sitter-bash = "0.25" tree-sitter-highlight = "0.25.10" ts-rs = "11" +uds_windows = "1.1.0" unicode-segmentation = "1.12.0" unicode-width = "0.2" url = "2" @@ -190,6 +211,7 @@ walkdir = "2.5.0" webbrowser = "1.0" which = "6" wildmatch = "2.5.0" + wiremock = "0.6" zeroize = "1.8.1" @@ -234,7 +256,12 @@ unwrap_used = "deny" # cargo-shear cannot see the platform-specific openssl-sys usage, so we # silence the false positive here instead of deleting a real dependency. [workspace.metadata.cargo-shear] -ignored = ["openssl-sys", "codex-utils-readiness"] +ignored = [ + "icu_provider", + "openssl-sys", + "codex-utils-readiness", + "codex-utils-tokenizer", +] [profile.release] lto = "fat" @@ -245,10 +272,16 @@ strip = "symbols" # See https://github.com/openai/codex/issues/1411 for details. codegen-units = 1 +[profile.ci-test] +debug = 1 # Reduce debug symbol size +inherits = "test" +opt-level = 0 + [patch.crates-io] # Uncomment to debug local changes. # ratatui = { path = "../../ratatui" } ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" } +crossterm = { git = "https://github.com/nornagon/crossterm", branch = "nornagon/color-query" } # Uncomment to debug local changes. # rmcp = { path = "../../rust-sdk/crates/rmcp" } diff --git a/codex-rs/README.md b/codex-rs/README.md index a05a8d48b1..36143558d3 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -11,7 +11,12 @@ npm i -g @openai/codex codex ``` -You can also install via Homebrew (`brew install codex`) or download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases). +You can also install via Homebrew (`brew install --cask codex`) or download a platform-specific release directly from our [GitHub Releases](https://github.com/openai/codex/releases). + +## Documentation quickstart + +- First run with Codex? Follow the walkthrough in [`docs/getting-started.md`](../docs/getting-started.md) for prompts, keyboard shortcuts, and session management. +- Already shipping with Codex and want deeper control? Jump to [`docs/advanced.md`](../docs/advanced.md) and the configuration reference at [`docs/config.md`](../docs/config.md). ## What's new in the Rust CLI @@ -47,30 +52,6 @@ You can enable notifications by configuring a script that is run whenever the ag To run Codex non-interactively, run `codex exec PROMPT` (you can also pass the prompt via `stdin`) and Codex will work on your task until it decides that it is done and exits. Output is printed to the terminal directly. You can set the `RUST_LOG` environment variable to see more about what's going on. -### Use `@` for file search - -Typing `@` triggers a fuzzy-filename search over the workspace root. Use up/down to select among the results and Tab or Enter to replace the `@` with the selected path. You can use Esc to cancel the search. - -### Esc–Esc to edit a previous message - -When the chat composer is empty, press Esc to prime “backtrack” mode. Press Esc again to open a transcript preview highlighting the last user message; press Esc repeatedly to step to older user messages. Press Enter to confirm and Codex will fork the conversation from that point, trim the visible transcript accordingly, and pre‑fill the composer with the selected user message so you can edit and resubmit it. - -In the transcript preview, the footer shows an `Esc edit prev` hint while editing is active. - -### `--cd`/`-C` flag - -Sometimes it is not convenient to `cd` to the directory you want Codex to use as the "working root" before running Codex. Fortunately, `codex` supports a `--cd` option so you can specify whatever folder you want. You can confirm that Codex is honoring `--cd` by double-checking the **workdir** it reports in the TUI at the start of a new session. - -### Shell completions - -Generate shell completion scripts via: - -```shell -codex completion bash -codex completion zsh -codex completion fish -``` - ### Experimenting with the Codex Sandbox To test to see what happens when a command is run under the sandbox provided by Codex, we provide the following subcommands in Codex CLI: @@ -82,6 +63,9 @@ codex sandbox macos [--full-auto] [COMMAND]... # Linux codex sandbox linux [--full-auto] [COMMAND]... +# Windows +codex sandbox windows [--full-auto] [COMMAND]... + # Legacy aliases codex debug seatbelt [--full-auto] [COMMAND]... codex debug landlock [--full-auto] [COMMAND]... diff --git a/codex-rs/app-server-protocol/Cargo.toml b/codex-rs/app-server-protocol/Cargo.toml index b18028fbe4..5aa1c765e7 100644 --- a/codex-rs/app-server-protocol/Cargo.toml +++ b/codex-rs/app-server-protocol/Cargo.toml @@ -11,8 +11,12 @@ path = "src/lib.rs" workspace = true [dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } codex-protocol = { workspace = true } +mcp-types = { workspace = true } paste = { workspace = true } +schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } strum_macros = { workspace = true } diff --git a/codex-rs/app-server-protocol/src/bin/export.rs b/codex-rs/app-server-protocol/src/bin/export.rs new file mode 100644 index 0000000000..d029ecbffa --- /dev/null +++ b/codex-rs/app-server-protocol/src/bin/export.rs @@ -0,0 +1,22 @@ +use anyhow::Result; +use clap::Parser; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command( + about = "Generate TypeScript bindings and JSON Schemas for the Codex app-server protocol" +)] +struct Args { + /// Output directory where generated files will be written + #[arg(short = 'o', long = "out", value_name = "DIR")] + out_dir: PathBuf, + + /// Optional Prettier executable path to format generated TypeScript files + #[arg(short = 'p', long = "prettier", value_name = "PRETTIER_BIN")] + prettier: Option, +} + +fn main() -> Result<()> { + let args = Args::parse(); + codex_app_server_protocol::generate_types(&args.out_dir, args.prettier.as_deref()) +} diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs new file mode 100644 index 0000000000..c5038a00d3 --- /dev/null +++ b/codex-rs/app-server-protocol/src/export.rs @@ -0,0 +1,908 @@ +use crate::ClientNotification; +use crate::ClientRequest; +use crate::ServerNotification; +use crate::ServerRequest; +use crate::export_client_notification_schemas; +use crate::export_client_param_schemas; +use crate::export_client_response_schemas; +use crate::export_client_responses; +use crate::export_server_notification_schemas; +use crate::export_server_param_schemas; +use crate::export_server_response_schemas; +use crate::export_server_responses; +use anyhow::Context; +use anyhow::Result; +use anyhow::anyhow; +use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::FileChange; +use codex_protocol::protocol::SandboxPolicy; +use schemars::JsonSchema; +use schemars::schema_for; +use serde::Serialize; +use serde_json::Map; +use serde_json::Value; +use std::collections::HashMap; +use std::collections::HashSet; +use std::ffi::OsStr; +use std::fs; +use std::io::Read; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use ts_rs::TS; + +const HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n"; + +#[derive(Clone)] +pub struct GeneratedSchema { + namespace: Option, + logical_name: String, + value: Value, + in_v1_dir: bool, +} + +impl GeneratedSchema { + fn namespace(&self) -> Option<&str> { + self.namespace.as_deref() + } + + fn logical_name(&self) -> &str { + &self.logical_name + } + + fn value(&self) -> &Value { + &self.value + } +} + +type JsonSchemaEmitter = fn(&Path) -> Result; +pub fn generate_types(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { + generate_ts(out_dir, prettier)?; + generate_json(out_dir)?; + Ok(()) +} + +pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { + let v2_out_dir = out_dir.join("v2"); + ensure_dir(out_dir)?; + ensure_dir(&v2_out_dir)?; + + ClientRequest::export_all_to(out_dir)?; + export_client_responses(out_dir)?; + ClientNotification::export_all_to(out_dir)?; + + ServerRequest::export_all_to(out_dir)?; + export_server_responses(out_dir)?; + ServerNotification::export_all_to(out_dir)?; + + generate_index_ts(out_dir)?; + generate_index_ts(&v2_out_dir)?; + + // Ensure our header is present on all TS files (root + subdirs like v2/). + let ts_files = ts_files_in_recursive(out_dir)?; + for file in &ts_files { + prepend_header_if_missing(file)?; + } + + // Optionally run Prettier on all generated TS files. + if let Some(prettier_bin) = prettier + && !ts_files.is_empty() + { + let status = Command::new(prettier_bin) + .arg("--write") + .args(ts_files.iter().map(|p| p.as_os_str())) + .status() + .with_context(|| format!("Failed to invoke Prettier at {}", prettier_bin.display()))?; + if !status.success() { + return Err(anyhow!("Prettier failed with status {status}")); + } + } + + Ok(()) +} + +pub fn generate_json(out_dir: &Path) -> Result<()> { + ensure_dir(out_dir)?; + let envelope_emitters: &[JsonSchemaEmitter] = &[ + |d| write_json_schema_with_return::(d, "RequestId"), + |d| write_json_schema_with_return::(d, "JSONRPCMessage"), + |d| write_json_schema_with_return::(d, "JSONRPCRequest"), + |d| write_json_schema_with_return::(d, "JSONRPCNotification"), + |d| write_json_schema_with_return::(d, "JSONRPCResponse"), + |d| write_json_schema_with_return::(d, "JSONRPCError"), + |d| write_json_schema_with_return::(d, "JSONRPCErrorError"), + |d| write_json_schema_with_return::(d, "ClientRequest"), + |d| write_json_schema_with_return::(d, "ServerRequest"), + |d| write_json_schema_with_return::(d, "ClientNotification"), + |d| write_json_schema_with_return::(d, "ServerNotification"), + |d| write_json_schema_with_return::(d, "EventMsg"), + |d| write_json_schema_with_return::(d, "FileChange"), + |d| write_json_schema_with_return::(d, "InputItem"), + |d| write_json_schema_with_return::(d, "ParsedCommand"), + |d| write_json_schema_with_return::(d, "SandboxPolicy"), + ]; + + let mut schemas: Vec = Vec::new(); + for emit in envelope_emitters { + schemas.push(emit(out_dir)?); + } + + schemas.extend(export_client_param_schemas(out_dir)?); + schemas.extend(export_client_response_schemas(out_dir)?); + schemas.extend(export_server_param_schemas(out_dir)?); + schemas.extend(export_server_response_schemas(out_dir)?); + schemas.extend(export_client_notification_schemas(out_dir)?); + schemas.extend(export_server_notification_schemas(out_dir)?); + + let bundle = build_schema_bundle(schemas)?; + write_pretty_json( + out_dir.join("codex_app_server_protocol.schemas.json"), + &bundle, + )?; + + Ok(()) +} + +fn build_schema_bundle(schemas: Vec) -> Result { + const SPECIAL_DEFINITIONS: &[&str] = &[ + "ClientNotification", + "ClientRequest", + "EventMsg", + "FileChange", + "InputItem", + "ParsedCommand", + "SandboxPolicy", + "ServerNotification", + "ServerRequest", + ]; + + let namespaced_types = collect_namespaced_types(&schemas); + let mut definitions = Map::new(); + + for schema in schemas { + let GeneratedSchema { + namespace, + logical_name, + mut value, + in_v1_dir, + } = schema; + + if let Some(ref ns) = namespace { + rewrite_refs_to_namespace(&mut value, ns); + } + + let mut forced_namespace_refs: Vec<(String, String)> = Vec::new(); + if let Value::Object(ref mut obj) = value + && let Some(defs) = obj.remove("definitions") + && let Value::Object(defs_obj) = defs + { + for (def_name, mut def_schema) in defs_obj { + if SPECIAL_DEFINITIONS.contains(&def_name.as_str()) { + continue; + } + annotate_schema(&mut def_schema, Some(def_name.as_str())); + let target_namespace = match namespace { + Some(ref ns) => Some(ns.clone()), + None => namespace_for_definition(&def_name, &namespaced_types) + .cloned() + .filter(|_| !in_v1_dir), + }; + if let Some(ref ns) = target_namespace { + if namespace.as_deref() == Some(ns.as_str()) { + rewrite_refs_to_namespace(&mut def_schema, ns); + insert_into_namespace(&mut definitions, ns, def_name.clone(), def_schema)?; + } else if !forced_namespace_refs + .iter() + .any(|(name, existing_ns)| name == &def_name && existing_ns == ns) + { + forced_namespace_refs.push((def_name.clone(), ns.clone())); + } + } else { + definitions.insert(def_name, def_schema); + } + } + } + + for (name, ns) in forced_namespace_refs { + rewrite_named_ref_to_namespace(&mut value, &ns, &name); + } + + if let Some(ref ns) = namespace { + insert_into_namespace(&mut definitions, ns, logical_name.clone(), value)?; + } else { + definitions.insert(logical_name, value); + } + } + + let mut root = Map::new(); + root.insert( + "$schema".to_string(), + Value::String("http://json-schema.org/draft-07/schema#".into()), + ); + root.insert( + "title".to_string(), + Value::String("CodexAppServerProtocol".into()), + ); + root.insert("type".to_string(), Value::String("object".into())); + root.insert("definitions".to_string(), Value::Object(definitions)); + + Ok(Value::Object(root)) +} + +fn insert_into_namespace( + definitions: &mut Map, + namespace: &str, + name: String, + schema: Value, +) -> Result<()> { + let entry = definitions + .entry(namespace.to_string()) + .or_insert_with(|| Value::Object(Map::new())); + match entry { + Value::Object(map) => { + map.insert(name, schema); + Ok(()) + } + _ => Err(anyhow!("expected namespace {namespace} to be an object")), + } +} + +fn write_json_schema_with_return(out_dir: &Path, name: &str) -> Result +where + T: JsonSchema, +{ + let file_stem = name.trim(); + let schema = schema_for!(T); + let mut schema_value = serde_json::to_value(schema)?; + annotate_schema(&mut schema_value, Some(file_stem)); + // If the name looks like a namespaced path (e.g., "v2::Type"), mirror + // the TypeScript layout and write to out_dir/v2/Type.json. Otherwise + // write alongside the legacy files. + let (raw_namespace, logical_name) = split_namespace(file_stem); + let out_path = if let Some(ns) = raw_namespace { + let dir = out_dir.join(ns); + ensure_dir(&dir)?; + dir.join(format!("{logical_name}.json")) + } else { + out_dir.join(format!("{file_stem}.json")) + }; + + write_pretty_json(out_path, &schema_value) + .with_context(|| format!("Failed to write JSON schema for {file_stem}"))?; + let namespace = match raw_namespace { + Some("v1") | None => None, + Some(ns) => Some(ns.to_string()), + }; + Ok(GeneratedSchema { + in_v1_dir: raw_namespace == Some("v1"), + namespace, + logical_name: logical_name.to_string(), + value: schema_value, + }) +} + +pub(crate) fn write_json_schema(out_dir: &Path, name: &str) -> Result +where + T: JsonSchema, +{ + write_json_schema_with_return::(out_dir, name) +} + +fn write_pretty_json(path: PathBuf, value: &impl Serialize) -> Result<()> { + let json = serde_json::to_vec_pretty(value) + .with_context(|| format!("Failed to serialize JSON schema to {}", path.display()))?; + fs::write(&path, json).with_context(|| format!("Failed to write {}", path.display()))?; + Ok(()) +} + +/// Split a fully-qualified type name like "v2::Type" into its namespace and logical name. +fn split_namespace(name: &str) -> (Option<&str>, &str) { + name.split_once("::") + .map_or((None, name), |(ns, rest)| (Some(ns), rest)) +} + +/// Recursively rewrite $ref values that point at "#/definitions/..." so that +/// they point to a namespaced location under the bundle. +fn rewrite_refs_to_namespace(value: &mut Value, ns: &str) { + match value { + Value::Object(obj) => { + if let Some(Value::String(r)) = obj.get_mut("$ref") + && let Some(suffix) = r.strip_prefix("#/definitions/") + { + let prefix = format!("{ns}/"); + if !suffix.starts_with(&prefix) { + *r = format!("#/definitions/{ns}/{suffix}"); + } + } + for v in obj.values_mut() { + rewrite_refs_to_namespace(v, ns); + } + } + Value::Array(items) => { + for v in items.iter_mut() { + rewrite_refs_to_namespace(v, ns); + } + } + _ => {} + } +} + +fn collect_namespaced_types(schemas: &[GeneratedSchema]) -> HashMap { + let mut types = HashMap::new(); + for schema in schemas { + if let Some(ns) = schema.namespace() { + types + .entry(schema.logical_name().to_string()) + .or_insert_with(|| ns.to_string()); + if let Some(Value::Object(defs)) = schema.value().get("definitions") { + for key in defs.keys() { + types.entry(key.clone()).or_insert_with(|| ns.to_string()); + } + } + if let Some(Value::Object(defs)) = schema.value().get("$defs") { + for key in defs.keys() { + types.entry(key.clone()).or_insert_with(|| ns.to_string()); + } + } + } + } + types +} + +fn namespace_for_definition<'a>( + name: &str, + types: &'a HashMap, +) -> Option<&'a String> { + if let Some(ns) = types.get(name) { + return Some(ns); + } + let trimmed = name.trim_end_matches(|c: char| c.is_ascii_digit()); + if trimmed != name { + return types.get(trimmed); + } + None +} + +fn variant_definition_name(base: &str, variant: &Value) -> Option { + if let Some(props) = variant.get("properties").and_then(Value::as_object) { + if let Some(method_literal) = literal_from_property(props, "method") { + let pascal = to_pascal_case(method_literal); + return Some(match base { + "ClientRequest" | "ServerRequest" => format!("{pascal}Request"), + "ClientNotification" | "ServerNotification" => format!("{pascal}Notification"), + _ => format!("{pascal}{base}"), + }); + } + + if let Some(type_literal) = literal_from_property(props, "type") { + let pascal = to_pascal_case(type_literal); + return Some(match base { + "EventMsg" => format!("{pascal}EventMsg"), + _ => format!("{pascal}{base}"), + }); + } + + if let Some(mode_literal) = literal_from_property(props, "mode") { + let pascal = to_pascal_case(mode_literal); + return Some(match base { + "SandboxPolicy" => format!("{pascal}SandboxPolicy"), + _ => format!("{pascal}{base}"), + }); + } + + if props.len() == 1 + && let Some(key) = props.keys().next() + { + let pascal = to_pascal_case(key); + return Some(format!("{pascal}{base}")); + } + } + + if let Some(required) = variant.get("required").and_then(Value::as_array) + && required.len() == 1 + && let Some(key) = required[0].as_str() + { + let pascal = to_pascal_case(key); + return Some(format!("{pascal}{base}")); + } + + None +} + +fn literal_from_property<'a>(props: &'a Map, key: &str) -> Option<&'a str> { + props.get(key).and_then(string_literal) +} + +fn string_literal(value: &Value) -> Option<&str> { + value.get("const").and_then(Value::as_str).or_else(|| { + value + .get("enum") + .and_then(Value::as_array) + .and_then(|arr| arr.first()) + .and_then(Value::as_str) + }) +} + +fn annotate_schema(value: &mut Value, base: Option<&str>) { + match value { + Value::Object(map) => annotate_object(map, base), + Value::Array(items) => { + for item in items { + annotate_schema(item, base); + } + } + _ => {} + } +} + +fn annotate_object(map: &mut Map, base: Option<&str>) { + let owner = map.get("title").and_then(Value::as_str).map(str::to_owned); + if let Some(owner) = owner.as_deref() + && let Some(Value::Object(props)) = map.get_mut("properties") + { + set_discriminator_titles(props, owner); + } + + if let Some(Value::Array(variants)) = map.get_mut("oneOf") { + annotate_variant_list(variants, base); + } + if let Some(Value::Array(variants)) = map.get_mut("anyOf") { + annotate_variant_list(variants, base); + } + + if let Some(Value::Object(defs)) = map.get_mut("definitions") { + for (name, schema) in defs.iter_mut() { + annotate_schema(schema, Some(name.as_str())); + } + } + + if let Some(Value::Object(defs)) = map.get_mut("$defs") { + for (name, schema) in defs.iter_mut() { + annotate_schema(schema, Some(name.as_str())); + } + } + + if let Some(Value::Object(props)) = map.get_mut("properties") { + for value in props.values_mut() { + annotate_schema(value, base); + } + } + + if let Some(items) = map.get_mut("items") { + annotate_schema(items, base); + } + + if let Some(additional) = map.get_mut("additionalProperties") { + annotate_schema(additional, base); + } + + for (key, child) in map.iter_mut() { + match key.as_str() { + "oneOf" + | "anyOf" + | "definitions" + | "$defs" + | "properties" + | "items" + | "additionalProperties" => {} + _ => annotate_schema(child, base), + } + } +} + +fn annotate_variant_list(variants: &mut [Value], base: Option<&str>) { + let mut seen = HashSet::new(); + + for variant in variants.iter() { + if let Some(name) = variant_title(variant) { + seen.insert(name.to_owned()); + } + } + + for variant in variants.iter_mut() { + let mut variant_name = variant_title(variant).map(str::to_owned); + + if variant_name.is_none() + && let Some(base_name) = base + && let Some(name) = variant_definition_name(base_name, variant) + { + let mut candidate = name.clone(); + let mut index = 2; + while seen.contains(&candidate) { + candidate = format!("{name}{index}"); + index += 1; + } + if let Some(obj) = variant.as_object_mut() { + obj.insert("title".into(), Value::String(candidate.clone())); + } + seen.insert(candidate.clone()); + variant_name = Some(candidate); + } + + if let Some(name) = variant_name.as_deref() + && let Some(obj) = variant.as_object_mut() + && let Some(Value::Object(props)) = obj.get_mut("properties") + { + set_discriminator_titles(props, name); + } + + annotate_schema(variant, base); + } +} + +const DISCRIMINATOR_KEYS: &[&str] = &["type", "method", "mode", "status", "role", "reason"]; + +fn set_discriminator_titles(props: &mut Map, owner: &str) { + for key in DISCRIMINATOR_KEYS { + if let Some(prop_schema) = props.get_mut(*key) + && string_literal(prop_schema).is_some() + && let Value::Object(prop_obj) = prop_schema + { + if prop_obj.contains_key("title") { + continue; + } + let suffix = to_pascal_case(key); + prop_obj.insert("title".into(), Value::String(format!("{owner}{suffix}"))); + } + } +} + +fn variant_title(value: &Value) -> Option<&str> { + value + .as_object() + .and_then(|obj| obj.get("title")) + .and_then(Value::as_str) +} + +fn to_pascal_case(input: &str) -> String { + let mut result = String::new(); + let mut capitalize_next = true; + + for c in input.chars() { + if c == '_' || c == '-' { + capitalize_next = true; + continue; + } + + if capitalize_next { + result.extend(c.to_uppercase()); + capitalize_next = false; + } else { + result.push(c); + } + } + + result +} + +fn ensure_dir(dir: &Path) -> Result<()> { + fs::create_dir_all(dir) + .with_context(|| format!("Failed to create output directory {}", dir.display())) +} + +fn rewrite_named_ref_to_namespace(value: &mut Value, ns: &str, name: &str) { + let direct = format!("#/definitions/{name}"); + let prefixed = format!("{direct}/"); + let replacement = format!("#/definitions/{ns}/{name}"); + let replacement_prefixed = format!("{replacement}/"); + match value { + Value::Object(obj) => { + if let Some(Value::String(reference)) = obj.get_mut("$ref") { + if reference == &direct { + *reference = replacement; + } else if let Some(rest) = reference.strip_prefix(&prefixed) { + *reference = format!("{replacement_prefixed}{rest}"); + } + } + for child in obj.values_mut() { + rewrite_named_ref_to_namespace(child, ns, name); + } + } + Value::Array(items) => { + for child in items { + rewrite_named_ref_to_namespace(child, ns, name); + } + } + _ => {} + } +} + +fn prepend_header_if_missing(path: &Path) -> Result<()> { + let mut content = String::new(); + { + let mut f = fs::File::open(path) + .with_context(|| format!("Failed to open {} for reading", path.display()))?; + f.read_to_string(&mut content) + .with_context(|| format!("Failed to read {}", path.display()))?; + } + + if content.starts_with(HEADER) { + return Ok(()); + } + + let mut f = fs::File::create(path) + .with_context(|| format!("Failed to open {} for writing", path.display()))?; + f.write_all(HEADER.as_bytes()) + .with_context(|| format!("Failed to write header to {}", path.display()))?; + f.write_all(content.as_bytes()) + .with_context(|| format!("Failed to write content to {}", path.display()))?; + Ok(()) +} + +fn ts_files_in(dir: &Path) -> Result> { + let mut files = Vec::new(); + for entry in + fs::read_dir(dir).with_context(|| format!("Failed to read dir {}", dir.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.is_file() && path.extension() == Some(OsStr::new("ts")) { + files.push(path); + } + } + files.sort(); + Ok(files) +} + +fn ts_files_in_recursive(dir: &Path) -> Result> { + let mut files = Vec::new(); + let mut stack = vec![dir.to_path_buf()]; + while let Some(d) = stack.pop() { + for entry in + fs::read_dir(&d).with_context(|| format!("Failed to read dir {}", d.display()))? + { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.is_file() && path.extension() == Some(OsStr::new("ts")) { + files.push(path); + } + } + } + files.sort(); + Ok(files) +} + +fn generate_index_ts(out_dir: &Path) -> Result { + let mut entries: Vec = Vec::new(); + let mut stems: Vec = ts_files_in(out_dir)? + .into_iter() + .filter_map(|p| { + let stem = p.file_stem()?.to_string_lossy().into_owned(); + if stem == "index" { None } else { Some(stem) } + }) + .collect(); + stems.sort(); + stems.dedup(); + + for name in stems { + entries.push(format!("export type {{ {name} }} from \"./{name}\";\n")); + } + + // If this is the root out_dir and a ./v2 folder exists with TS files, + // expose it as a namespace to avoid symbol collisions at the root. + let v2_dir = out_dir.join("v2"); + let has_v2_ts = ts_files_in(&v2_dir).map(|v| !v.is_empty()).unwrap_or(false); + if has_v2_ts { + entries.push("export * as v2 from \"./v2\";\n".to_string()); + } + + let mut content = + String::with_capacity(HEADER.len() + entries.iter().map(String::len).sum::()); + content.push_str(HEADER); + for line in &entries { + content.push_str(line); + } + + let index_path = out_dir.join("index.ts"); + let mut f = fs::File::create(&index_path) + .with_context(|| format!("Failed to create {}", index_path.display()))?; + f.write_all(content.as_bytes()) + .with_context(|| format!("Failed to write {}", index_path.display()))?; + Ok(index_path) +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + use std::collections::BTreeSet; + use std::fs; + use std::path::PathBuf; + use uuid::Uuid; + + #[test] + fn generated_ts_has_no_optional_nullable_fields() -> Result<()> { + // Assert that there are no types of the form "?: T | null" in the generated TS files. + let output_dir = std::env::temp_dir().join(format!("codex_ts_types_{}", Uuid::now_v7())); + fs::create_dir(&output_dir)?; + + struct TempDirGuard(PathBuf); + + impl Drop for TempDirGuard { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + let _guard = TempDirGuard(output_dir.clone()); + + generate_ts(&output_dir, None)?; + + let mut undefined_offenders = Vec::new(); + let mut optional_nullable_offenders = BTreeSet::new(); + let mut stack = vec![output_dir]; + while let Some(dir) = stack.pop() { + for entry in fs::read_dir(&dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + stack.push(path); + continue; + } + + if matches!(path.extension().and_then(|ext| ext.to_str()), Some("ts")) { + let contents = fs::read_to_string(&path)?; + if contents.contains("| undefined") { + undefined_offenders.push(path.clone()); + } + + const SKIP_PREFIXES: &[&str] = &[ + "const ", + "let ", + "var ", + "export const ", + "export let ", + "export var ", + ]; + + let mut search_start = 0; + while let Some(idx) = contents[search_start..].find("| null") { + let abs_idx = search_start + idx; + // Find the property-colon for this field by scanning forward + // from the start of the segment and ignoring nested braces, + // brackets, and parens. This avoids colons inside nested + // type literals like `{ [k in string]?: string }`. + + let line_start_idx = + contents[..abs_idx].rfind('\n').map(|i| i + 1).unwrap_or(0); + + let mut segment_start_idx = line_start_idx; + if let Some(rel_idx) = contents[line_start_idx..abs_idx].rfind(',') { + segment_start_idx = segment_start_idx.max(line_start_idx + rel_idx + 1); + } + if let Some(rel_idx) = contents[line_start_idx..abs_idx].rfind('{') { + segment_start_idx = segment_start_idx.max(line_start_idx + rel_idx + 1); + } + if let Some(rel_idx) = contents[line_start_idx..abs_idx].rfind('}') { + segment_start_idx = segment_start_idx.max(line_start_idx + rel_idx + 1); + } + + // Scan forward for the colon that separates the field name from its type. + let mut level_brace = 0_i32; + let mut level_brack = 0_i32; + let mut level_paren = 0_i32; + let mut in_single = false; + let mut in_double = false; + let mut escape = false; + let mut prop_colon_idx = None; + for (i, ch) in contents[segment_start_idx..abs_idx].char_indices() { + let idx_abs = segment_start_idx + i; + if escape { + escape = false; + continue; + } + match ch { + '\\' => { + // Only treat as escape when inside a string. + if in_single || in_double { + escape = true; + } + } + '\'' => { + if !in_double { + in_single = !in_single; + } + } + '"' => { + if !in_single { + in_double = !in_double; + } + } + '{' if !in_single && !in_double => level_brace += 1, + '}' if !in_single && !in_double => level_brace -= 1, + '[' if !in_single && !in_double => level_brack += 1, + ']' if !in_single && !in_double => level_brack -= 1, + '(' if !in_single && !in_double => level_paren += 1, + ')' if !in_single && !in_double => level_paren -= 1, + ':' if !in_single + && !in_double + && level_brace == 0 + && level_brack == 0 + && level_paren == 0 => + { + prop_colon_idx = Some(idx_abs); + break; + } + _ => {} + } + } + + let Some(colon_idx) = prop_colon_idx else { + search_start = abs_idx + 5; + continue; + }; + + let mut field_prefix = contents[segment_start_idx..colon_idx].trim(); + if field_prefix.is_empty() { + search_start = abs_idx + 5; + continue; + } + + if let Some(comment_idx) = field_prefix.rfind("*/") { + field_prefix = field_prefix[comment_idx + 2..].trim_start(); + } + + if field_prefix.is_empty() { + search_start = abs_idx + 5; + continue; + } + + if SKIP_PREFIXES + .iter() + .any(|prefix| field_prefix.starts_with(prefix)) + { + search_start = abs_idx + 5; + continue; + } + + if field_prefix.contains('(') { + search_start = abs_idx + 5; + continue; + } + + // If the last non-whitespace before ':' is '?', then this is an + // optional field with a nullable type (i.e., "?: T | null"), + // which we explicitly disallow. + if field_prefix.chars().rev().find(|c| !c.is_whitespace()) == Some('?') { + let line_number = + contents[..abs_idx].chars().filter(|c| *c == '\n').count() + 1; + let offending_line_end = contents[line_start_idx..] + .find('\n') + .map(|i| line_start_idx + i) + .unwrap_or(contents.len()); + let offending_snippet = + contents[line_start_idx..offending_line_end].trim(); + + optional_nullable_offenders.insert(format!( + "{}:{}: {offending_snippet}", + path.display(), + line_number + )); + } + + search_start = abs_idx + 5; + } + } + } + } + + assert!( + undefined_offenders.is_empty(), + "Generated TypeScript still includes unions with `undefined` in {undefined_offenders:?}" + ); + + // If this assertion fails, it means a field was generated as + // "?: T | null" — i.e., both optional (undefined) and nullable (null). + // We only want either "?: T" or ": T | null". + assert!( + optional_nullable_offenders.is_empty(), + "Generated TypeScript has optional fields with nullable types (disallowed '?: T | null'), add #[ts(optional)] to fix:\n{optional_nullable_offenders:?}" + ); + + Ok(()) + } +} diff --git a/codex-rs/app-server-protocol/src/jsonrpc_lite.rs b/codex-rs/app-server-protocol/src/jsonrpc_lite.rs index 9d6d6da06f..65ffa2a005 100644 --- a/codex-rs/app-server-protocol/src/jsonrpc_lite.rs +++ b/codex-rs/app-server-protocol/src/jsonrpc_lite.rs @@ -1,13 +1,14 @@ //! We do not do true JSON-RPC 2.0, as we neither send nor expect the //! "jsonrpc": "2.0" field. +use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; use ts_rs::TS; pub const JSONRPC_VERSION: &str = "2.0"; -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, Hash, Eq, JsonSchema, TS)] #[serde(untagged)] pub enum RequestId { String(String), @@ -18,7 +19,7 @@ pub enum RequestId { pub type Result = serde_json::Value; /// Refers to any valid JSON-RPC object that can be decoded off the wire, or encoded to be sent. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] #[serde(untagged)] pub enum JSONRPCMessage { Request(JSONRPCRequest), @@ -28,40 +29,43 @@ pub enum JSONRPCMessage { } /// A request that expects a response. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCRequest { pub id: RequestId, pub method: String, #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub params: Option, } /// A notification which does not expect a response. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCNotification { pub method: String, #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub params: Option, } /// A successful (non-error) response to a request. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCResponse { pub id: RequestId, pub result: Result, } /// A response to a request that indicates an error occurred. -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCError { pub error: JSONRPCErrorError, pub id: RequestId, } -#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, TS)] +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize, JsonSchema, TS)] pub struct JSONRPCErrorError { pub code: i64, #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub data: Option, pub message: String, } diff --git a/codex-rs/app-server-protocol/src/lib.rs b/codex-rs/app-server-protocol/src/lib.rs index 517702bbd8..9c02ea9246 100644 --- a/codex-rs/app-server-protocol/src/lib.rs +++ b/codex-rs/app-server-protocol/src/lib.rs @@ -1,5 +1,11 @@ +mod export; mod jsonrpc_lite; mod protocol; +pub use export::generate_json; +pub use export::generate_ts; +pub use export::generate_types; pub use jsonrpc_lite::*; -pub use protocol::*; +pub use protocol::common::*; +pub use protocol::v1::*; +pub use protocol::v2::*; diff --git a/codex-rs/app-server-protocol/src/protocol.rs b/codex-rs/app-server-protocol/src/protocol.rs deleted file mode 100644 index 916560c60b..0000000000 --- a/codex-rs/app-server-protocol/src/protocol.rs +++ /dev/null @@ -1,943 +0,0 @@ -use std::collections::HashMap; -use std::path::PathBuf; - -use crate::JSONRPCNotification; -use crate::JSONRPCRequest; -use crate::RequestId; -use codex_protocol::ConversationId; -use codex_protocol::config_types::ReasoningEffort; -use codex_protocol::config_types::ReasoningSummary; -use codex_protocol::config_types::SandboxMode; -use codex_protocol::config_types::Verbosity; -use codex_protocol::parse_command::ParsedCommand; -use codex_protocol::protocol::AskForApproval; -use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::FileChange; -use codex_protocol::protocol::ReviewDecision; -use codex_protocol::protocol::SandboxPolicy; -use codex_protocol::protocol::TurnAbortReason; -use paste::paste; -use serde::Deserialize; -use serde::Serialize; -use strum_macros::Display; -use ts_rs::TS; -use uuid::Uuid; - -#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, TS)] -#[ts(type = "string")] -pub struct GitSha(pub String); - -impl GitSha { - pub fn new(sha: &str) -> Self { - Self(sha.to_string()) - } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Display, TS)] -#[serde(rename_all = "lowercase")] -pub enum AuthMode { - ApiKey, - ChatGPT, -} - -/// Generates an `enum ClientRequest` where each variant is a request that the -/// client can send to the server. Each variant has associated `params` and -/// `response` types. Also generates a `export_client_responses()` function to -/// export all response types to TypeScript. -macro_rules! client_request_definitions { - ( - $( - $(#[$variant_meta:meta])* - $variant:ident { - params: $(#[$params_meta:meta])* $params:ty, - response: $response:ty, - } - ),* $(,)? - ) => { - /// Request from the client to the server. - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] - #[serde(tag = "method", rename_all = "camelCase")] - pub enum ClientRequest { - $( - $(#[$variant_meta])* - $variant { - #[serde(rename = "id")] - request_id: RequestId, - $(#[$params_meta])* - params: $params, - }, - )* - } - - pub fn export_client_responses( - out_dir: &::std::path::Path, - ) -> ::std::result::Result<(), ::ts_rs::ExportError> { - $( - <$response as ::ts_rs::TS>::export_all_to(out_dir)?; - )* - Ok(()) - } - }; -} - -client_request_definitions! { - Initialize { - params: InitializeParams, - response: InitializeResponse, - }, - NewConversation { - params: NewConversationParams, - response: NewConversationResponse, - }, - /// List recorded Codex conversations (rollouts) with optional pagination and search. - ListConversations { - params: ListConversationsParams, - response: ListConversationsResponse, - }, - /// Resume a recorded Codex conversation from a rollout file. - ResumeConversation { - params: ResumeConversationParams, - response: ResumeConversationResponse, - }, - ArchiveConversation { - params: ArchiveConversationParams, - response: ArchiveConversationResponse, - }, - SendUserMessage { - params: SendUserMessageParams, - response: SendUserMessageResponse, - }, - SendUserTurn { - params: SendUserTurnParams, - response: SendUserTurnResponse, - }, - InterruptConversation { - params: InterruptConversationParams, - response: InterruptConversationResponse, - }, - AddConversationListener { - params: AddConversationListenerParams, - response: AddConversationSubscriptionResponse, - }, - RemoveConversationListener { - params: RemoveConversationListenerParams, - response: RemoveConversationSubscriptionResponse, - }, - GitDiffToRemote { - params: GitDiffToRemoteParams, - response: GitDiffToRemoteResponse, - }, - LoginApiKey { - params: LoginApiKeyParams, - response: LoginApiKeyResponse, - }, - LoginChatGpt { - params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, - response: LoginChatGptResponse, - }, - CancelLoginChatGpt { - params: CancelLoginChatGptParams, - response: CancelLoginChatGptResponse, - }, - LogoutChatGpt { - params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, - response: LogoutChatGptResponse, - }, - GetAuthStatus { - params: GetAuthStatusParams, - response: GetAuthStatusResponse, - }, - GetUserSavedConfig { - params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, - response: GetUserSavedConfigResponse, - }, - SetDefaultModel { - params: SetDefaultModelParams, - response: SetDefaultModelResponse, - }, - GetUserAgent { - params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, - response: GetUserAgentResponse, - }, - UserInfo { - params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, - response: UserInfoResponse, - }, - FuzzyFileSearch { - params: FuzzyFileSearchParams, - response: FuzzyFileSearchResponse, - }, - /// Execute a command (argv vector) under the server's sandbox. - ExecOneOffCommand { - params: ExecOneOffCommandParams, - response: ExecOneOffCommandResponse, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] -#[serde(rename_all = "camelCase")] -pub struct InitializeParams { - pub client_info: ClientInfo, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] -#[serde(rename_all = "camelCase")] -pub struct ClientInfo { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option, - pub version: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct InitializeResponse { - pub user_agent: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] -#[serde(rename_all = "camelCase")] -pub struct NewConversationParams { - /// Optional override for the model name (e.g. "o3", "o4-mini"). - #[serde(skip_serializing_if = "Option::is_none")] - pub model: Option, - - /// Configuration profile from config.toml to specify default options. - #[serde(skip_serializing_if = "Option::is_none")] - pub profile: Option, - - /// Working directory for the session. If relative, it is resolved against - /// the server process's current working directory. - #[serde(skip_serializing_if = "Option::is_none")] - pub cwd: Option, - - /// Approval policy for shell commands generated by the model: - /// `untrusted`, `on-failure`, `on-request`, `never`. - #[serde(skip_serializing_if = "Option::is_none")] - pub approval_policy: Option, - - /// Sandbox mode: `read-only`, `workspace-write`, or `danger-full-access`. - #[serde(skip_serializing_if = "Option::is_none")] - pub sandbox: Option, - - /// Individual config settings that will override what is in - /// CODEX_HOME/config.toml. - #[serde(skip_serializing_if = "Option::is_none")] - pub config: Option>, - - /// The set of instructions to use instead of the default ones. - #[serde(skip_serializing_if = "Option::is_none")] - pub base_instructions: Option, - - /// Whether to include the plan tool in the conversation. - #[serde(skip_serializing_if = "Option::is_none")] - pub include_plan_tool: Option, - - /// Whether to include the apply patch tool in the conversation. - #[serde(skip_serializing_if = "Option::is_none")] - pub include_apply_patch_tool: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct NewConversationResponse { - pub conversation_id: ConversationId, - pub model: String, - /// Note this could be ignored by the model. - #[serde(skip_serializing_if = "Option::is_none")] - pub reasoning_effort: Option, - pub rollout_path: PathBuf, -} - -#[derive(Serialize, Deserialize, Debug, Clone, TS)] -#[serde(rename_all = "camelCase")] -pub struct ResumeConversationResponse { - pub conversation_id: ConversationId, - pub model: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub initial_messages: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] -#[serde(rename_all = "camelCase")] -pub struct ListConversationsParams { - /// Optional page size; defaults to a reasonable server-side value. - #[serde(skip_serializing_if = "Option::is_none")] - pub page_size: Option, - /// Opaque pagination cursor returned by a previous call. - #[serde(skip_serializing_if = "Option::is_none")] - pub cursor: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct ConversationSummary { - pub conversation_id: ConversationId, - pub path: PathBuf, - pub preview: String, - /// RFC3339 timestamp string for the session start, if available. - #[serde(skip_serializing_if = "Option::is_none")] - pub timestamp: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct ListConversationsResponse { - pub items: Vec, - /// Opaque cursor to pass to the next call to continue after the last item. - /// if None, there are no more items to return. - #[serde(skip_serializing_if = "Option::is_none")] - pub next_cursor: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct ResumeConversationParams { - /// Absolute path to the rollout JSONL file. - pub path: PathBuf, - /// Optional overrides to apply when spawning the resumed session. - #[serde(skip_serializing_if = "Option::is_none")] - pub overrides: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct AddConversationSubscriptionResponse { - pub subscription_id: Uuid, -} - -/// The [`ConversationId`] must match the `rollout_path`. -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct ArchiveConversationParams { - pub conversation_id: ConversationId, - pub rollout_path: PathBuf, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct ArchiveConversationResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct RemoveConversationSubscriptionResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct LoginApiKeyParams { - pub api_key: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct LoginApiKeyResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct LoginChatGptResponse { - pub login_id: Uuid, - /// URL the client should open in a browser to initiate the OAuth flow. - pub auth_url: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct GitDiffToRemoteResponse { - pub sha: GitSha, - pub diff: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct CancelLoginChatGptParams { - pub login_id: Uuid, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct GitDiffToRemoteParams { - pub cwd: PathBuf, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct CancelLoginChatGptResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct LogoutChatGptParams {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct LogoutChatGptResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct GetAuthStatusParams { - /// If true, include the current auth token (if available) in the response. - #[serde(skip_serializing_if = "Option::is_none")] - pub include_token: Option, - /// If true, attempt to refresh the token before returning status. - #[serde(skip_serializing_if = "Option::is_none")] - pub refresh_token: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct ExecOneOffCommandParams { - /// Command argv to execute. - pub command: Vec, - /// Timeout of the command in milliseconds. - /// If not specified, a sensible default is used server-side. - pub timeout_ms: Option, - /// Optional working directory for the process. Defaults to server config cwd. - #[serde(skip_serializing_if = "Option::is_none")] - pub cwd: Option, - /// Optional explicit sandbox policy overriding the server default. - #[serde(skip_serializing_if = "Option::is_none")] - pub sandbox_policy: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct ExecOneOffCommandResponse { - pub exit_code: i32, - pub stdout: String, - pub stderr: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct GetAuthStatusResponse { - #[serde(skip_serializing_if = "Option::is_none")] - pub auth_method: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub auth_token: Option, - - // Indicates that auth method must be valid to use the server. - // This can be false if using a custom provider that is configured - // with requires_openai_auth == false. - #[serde(skip_serializing_if = "Option::is_none")] - pub requires_openai_auth: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct GetUserAgentResponse { - pub user_agent: String, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct UserInfoResponse { - /// Note: `alleged_user_email` is not currently verified. We read it from - /// the local auth.json, which the user could theoretically modify. In the - /// future, we may add logic to verify the email against the server before - /// returning it. - pub alleged_user_email: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct GetUserSavedConfigResponse { - pub config: UserSavedConfig, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct SetDefaultModelParams { - /// If set to None, this means `model` should be cleared in config.toml. - #[serde(skip_serializing_if = "Option::is_none")] - pub model: Option, - /// If set to None, this means `model_reasoning_effort` should be cleared - /// in config.toml. - #[serde(skip_serializing_if = "Option::is_none")] - pub reasoning_effort: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct SetDefaultModelResponse {} - -/// UserSavedConfig contains a subset of the config. It is meant to expose mcp -/// client-configurable settings that can be specified in the NewConversation -/// and SendUserTurn requests. -#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -pub struct UserSavedConfig { - /// Approvals - #[serde(skip_serializing_if = "Option::is_none")] - pub approval_policy: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub sandbox_mode: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub sandbox_settings: Option, - - /// Model-specific configuration - #[serde(skip_serializing_if = "Option::is_none")] - pub model: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub model_reasoning_effort: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub model_reasoning_summary: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub model_verbosity: Option, - - /// Tools - #[serde(skip_serializing_if = "Option::is_none")] - pub tools: Option, - - /// Profiles - #[serde(skip_serializing_if = "Option::is_none")] - pub profile: Option, - #[serde(default)] - pub profiles: HashMap, -} - -/// MCP representation of a [`codex_core::config_profile::ConfigProfile`]. -#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -pub struct Profile { - pub model: Option, - /// The key in the `model_providers` map identifying the - /// [`ModelProviderInfo`] to use. - pub model_provider: Option, - pub approval_policy: Option, - pub model_reasoning_effort: Option, - pub model_reasoning_summary: Option, - pub model_verbosity: Option, - pub chatgpt_base_url: Option, -} -/// MCP representation of a [`codex_core::config::ToolsToml`]. -#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -pub struct Tools { - #[serde(skip_serializing_if = "Option::is_none")] - pub web_search: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub view_image: Option, -} - -/// MCP representation of a [`codex_core::config_types::SandboxWorkspaceWrite`]. -#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, TS)] -#[serde(rename_all = "camelCase")] -pub struct SandboxSettings { - #[serde(default)] - pub writable_roots: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub network_access: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub exclude_tmpdir_env_var: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub exclude_slash_tmp: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct SendUserMessageParams { - pub conversation_id: ConversationId, - pub items: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct SendUserTurnParams { - pub conversation_id: ConversationId, - pub items: Vec, - pub cwd: PathBuf, - pub approval_policy: AskForApproval, - pub sandbox_policy: SandboxPolicy, - pub model: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub effort: Option, - pub summary: ReasoningSummary, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct SendUserTurnResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct InterruptConversationParams { - pub conversation_id: ConversationId, -} - -#[derive(Serialize, Deserialize, Debug, Clone, TS)] -#[serde(rename_all = "camelCase")] -pub struct InterruptConversationResponse { - pub abort_reason: TurnAbortReason, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct SendUserMessageResponse {} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct AddConversationListenerParams { - pub conversation_id: ConversationId, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct RemoveConversationListenerParams { - pub subscription_id: Uuid, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -#[serde(tag = "type", content = "data")] -pub enum InputItem { - Text { - text: String, - }, - /// Pre‑encoded data: URI image. - Image { - image_url: String, - }, - - /// Local image path provided by the user. This will be converted to an - /// `Image` variant (base64 data URL) during request serialization. - LocalImage { - path: PathBuf, - }, -} - -/// Generates an `enum ServerRequest` where each variant is a request that the -/// server can send to the client along with the corresponding params and -/// response types. It also generates helper types used by the app/server -/// infrastructure (payload enum, request constructor, and export helpers). -macro_rules! server_request_definitions { - ( - $( - $(#[$variant_meta:meta])* - $variant:ident - ),* $(,)? - ) => { - paste! { - /// Request initiated from the server and sent to the client. - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] - #[serde(tag = "method", rename_all = "camelCase")] - pub enum ServerRequest { - $( - $(#[$variant_meta])* - $variant { - #[serde(rename = "id")] - request_id: RequestId, - params: [<$variant Params>], - }, - )* - } - - #[derive(Debug, Clone, PartialEq)] - pub enum ServerRequestPayload { - $( $variant([<$variant Params>]), )* - } - - impl ServerRequestPayload { - pub fn request_with_id(self, request_id: RequestId) -> ServerRequest { - match self { - $(Self::$variant(params) => ServerRequest::$variant { request_id, params },)* - } - } - } - } - - pub fn export_server_responses( - out_dir: &::std::path::Path, - ) -> ::std::result::Result<(), ::ts_rs::ExportError> { - paste! { - $(<[<$variant Response>] as ::ts_rs::TS>::export_all_to(out_dir)?;)* - } - Ok(()) - } - }; -} - -impl TryFrom for ServerRequest { - type Error = serde_json::Error; - - fn try_from(value: JSONRPCRequest) -> Result { - serde_json::from_value(serde_json::to_value(value)?) - } -} - -server_request_definitions! { - /// Request to approve a patch. - ApplyPatchApproval, - /// Request to exec a command. - ExecCommandApproval, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct ApplyPatchApprovalParams { - pub conversation_id: ConversationId, - /// Use to correlate this with [codex_core::protocol::PatchApplyBeginEvent] - /// and [codex_core::protocol::PatchApplyEndEvent]. - pub call_id: String, - pub file_changes: HashMap, - /// Optional explanatory reason (e.g. request for extra write access). - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option, - /// When set, the agent is asking the user to allow writes under this root - /// for the remainder of the session (unclear if this is honored today). - #[serde(skip_serializing_if = "Option::is_none")] - pub grant_root: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct ExecCommandApprovalParams { - pub conversation_id: ConversationId, - /// Use to correlate this with [codex_core::protocol::ExecCommandBeginEvent] - /// and [codex_core::protocol::ExecCommandEndEvent]. - pub call_id: String, - pub command: Vec, - pub cwd: PathBuf, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option, - pub parsed_cmd: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -pub struct ExecCommandApprovalResponse { - pub decision: ReviewDecision, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -pub struct ApplyPatchApprovalResponse { - pub decision: ReviewDecision, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -#[ts(rename_all = "camelCase")] -pub struct FuzzyFileSearchParams { - pub query: String, - pub roots: Vec, - // if provided, will cancel any previous request that used the same value - #[serde(skip_serializing_if = "Option::is_none")] - pub cancellation_token: Option, -} - -/// Superset of [`codex_file_search::FileMatch`] -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -pub struct FuzzyFileSearchResult { - pub root: String, - pub path: String, - pub file_name: String, - pub score: u32, - #[serde(skip_serializing_if = "Option::is_none")] - pub indices: Option>, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -pub struct FuzzyFileSearchResponse { - pub files: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct LoginChatGptCompleteNotification { - pub login_id: Uuid, - pub success: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone, TS)] -#[serde(rename_all = "camelCase")] -pub struct SessionConfiguredNotification { - /// Name left as session_id instead of conversation_id for backwards compatibility. - pub session_id: ConversationId, - - /// Tell the client what model is being queried. - pub model: String, - - /// The effort the model is putting into reasoning about the user's request. - #[serde(skip_serializing_if = "Option::is_none")] - pub reasoning_effort: Option, - - /// Identifier of the history log file (inode on Unix, 0 otherwise). - pub history_log_id: u64, - - /// Current number of entries in the history log. - #[ts(type = "number")] - pub history_entry_count: usize, - - /// Optional initial messages (as events) for resumed sessions. - /// When present, UIs can use these to seed the history. - #[serde(skip_serializing_if = "Option::is_none")] - pub initial_messages: Option>, - - pub rollout_path: PathBuf, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct AuthStatusChangeNotification { - /// Current authentication method; omitted if signed out. - #[serde(skip_serializing_if = "Option::is_none")] - pub auth_method: Option, -} - -/// Notification sent from the server to the client. -#[derive(Serialize, Deserialize, Debug, Clone, TS, Display)] -#[serde(tag = "method", content = "params", rename_all = "camelCase")] -#[strum(serialize_all = "camelCase")] -pub enum ServerNotification { - /// Authentication status changed - AuthStatusChange(AuthStatusChangeNotification), - - /// ChatGPT login flow completed - LoginChatGptComplete(LoginChatGptCompleteNotification), - - /// The special session configured event for a new or resumed conversation. - SessionConfigured(SessionConfiguredNotification), -} - -impl ServerNotification { - pub fn to_params(self) -> Result { - match self { - ServerNotification::AuthStatusChange(params) => serde_json::to_value(params), - ServerNotification::LoginChatGptComplete(params) => serde_json::to_value(params), - ServerNotification::SessionConfigured(params) => serde_json::to_value(params), - } - } -} - -impl TryFrom for ServerNotification { - type Error = serde_json::Error; - - fn try_from(value: JSONRPCNotification) -> Result { - serde_json::from_value(serde_json::to_value(value)?) - } -} - -/// Notification sent from the client to the server. -#[derive(Serialize, Deserialize, Debug, Clone, TS, Display)] -#[serde(tag = "method", content = "params", rename_all = "camelCase")] -#[strum(serialize_all = "camelCase")] -pub enum ClientNotification { - Initialized, -} - -#[cfg(test)] -mod tests { - use super::*; - use anyhow::Result; - use pretty_assertions::assert_eq; - use serde_json::json; - - #[test] - fn serialize_new_conversation() -> Result<()> { - let request = ClientRequest::NewConversation { - request_id: RequestId::Integer(42), - params: NewConversationParams { - model: Some("gpt-5-codex".to_string()), - profile: None, - cwd: None, - approval_policy: Some(AskForApproval::OnRequest), - sandbox: None, - config: None, - base_instructions: None, - include_plan_tool: None, - include_apply_patch_tool: None, - }, - }; - assert_eq!( - json!({ - "method": "newConversation", - "id": 42, - "params": { - "model": "gpt-5-codex", - "approvalPolicy": "on-request" - } - }), - serde_json::to_value(&request)?, - ); - Ok(()) - } - - #[test] - fn conversation_id_serializes_as_plain_string() -> Result<()> { - let id = ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; - - assert_eq!( - json!("67e55044-10b1-426f-9247-bb680e5fe0c8"), - serde_json::to_value(id)? - ); - Ok(()) - } - - #[test] - fn conversation_id_deserializes_from_plain_string() -> Result<()> { - let id: ConversationId = - serde_json::from_value(json!("67e55044-10b1-426f-9247-bb680e5fe0c8"))?; - - assert_eq!( - ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?, - id, - ); - Ok(()) - } - - #[test] - fn serialize_client_notification() -> Result<()> { - let notification = ClientNotification::Initialized; - // Note there is no "params" field for this notification. - assert_eq!( - json!({ - "method": "initialized", - }), - serde_json::to_value(¬ification)?, - ); - Ok(()) - } - - #[test] - fn serialize_server_request() -> Result<()> { - let conversation_id = ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; - let params = ExecCommandApprovalParams { - conversation_id, - call_id: "call-42".to_string(), - command: vec!["echo".to_string(), "hello".to_string()], - cwd: PathBuf::from("/tmp"), - reason: Some("because tests".to_string()), - parsed_cmd: vec![ParsedCommand::Unknown { - cmd: "echo hello".to_string(), - }], - }; - let request = ServerRequest::ExecCommandApproval { - request_id: RequestId::Integer(7), - params: params.clone(), - }; - - assert_eq!( - json!({ - "method": "execCommandApproval", - "id": 7, - "params": { - "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8", - "callId": "call-42", - "command": ["echo", "hello"], - "cwd": "/tmp", - "reason": "because tests", - "parsedCmd": [ - { - "type": "unknown", - "cmd": "echo hello" - } - ] - } - }), - serde_json::to_value(&request)?, - ); - - let payload = ServerRequestPayload::ExecCommandApproval(params); - assert_eq!(payload.request_with_id(RequestId::Integer(7)), request); - Ok(()) - } -} diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs new file mode 100644 index 0000000000..2c52067bbd --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -0,0 +1,824 @@ +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; + +use crate::JSONRPCNotification; +use crate::JSONRPCRequest; +use crate::RequestId; +use crate::export::GeneratedSchema; +use crate::export::write_json_schema; +use crate::protocol::v1; +use crate::protocol::v2; +use codex_protocol::ConversationId; +use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::protocol::FileChange; +use codex_protocol::protocol::ReviewDecision; +use codex_protocol::protocol::SandboxCommandAssessment; +use paste::paste; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use strum_macros::Display; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)] +#[ts(type = "string")] +pub struct GitSha(pub String); + +impl GitSha { + pub fn new(sha: &str) -> Self { + Self(sha.to_string()) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)] +#[serde(rename_all = "lowercase")] +pub enum AuthMode { + ApiKey, + ChatGPT, +} + +/// Generates an `enum ClientRequest` where each variant is a request that the +/// client can send to the server. Each variant has associated `params` and +/// `response` types. Also generates a `export_client_responses()` function to +/// export all response types to TypeScript. +macro_rules! client_request_definitions { + ( + $( + $(#[$variant_meta:meta])* + $variant:ident { + params: $(#[$params_meta:meta])* $params:ty, + response: $response:ty, + } + ),* $(,)? + ) => { + /// Request from the client to the server. + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] + #[serde(tag = "method", rename_all = "camelCase")] + pub enum ClientRequest { + $( + $(#[$variant_meta])* + $variant { + #[serde(rename = "id")] + request_id: RequestId, + $(#[$params_meta])* + params: $params, + }, + )* + } + + pub fn export_client_responses( + out_dir: &::std::path::Path, + ) -> ::std::result::Result<(), ::ts_rs::ExportError> { + $( + <$response as ::ts_rs::TS>::export_all_to(out_dir)?; + )* + Ok(()) + } + + #[allow(clippy::vec_init_then_push)] + pub fn export_client_response_schemas( + out_dir: &::std::path::Path, + ) -> ::anyhow::Result> { + let mut schemas = Vec::new(); + $( + schemas.push(write_json_schema::<$response>(out_dir, stringify!($response))?); + )* + Ok(schemas) + } + + #[allow(clippy::vec_init_then_push)] + pub fn export_client_param_schemas( + out_dir: &::std::path::Path, + ) -> ::anyhow::Result> { + let mut schemas = Vec::new(); + $( + schemas.push(write_json_schema::<$params>(out_dir, stringify!($params))?); + )* + Ok(schemas) + } + }; +} + +client_request_definitions! { + /// NEW APIs + // Thread lifecycle + #[serde(rename = "thread/start")] + #[ts(rename = "thread/start")] + ThreadStart { + params: v2::ThreadStartParams, + response: v2::ThreadStartResponse, + }, + #[serde(rename = "thread/resume")] + #[ts(rename = "thread/resume")] + ThreadResume { + params: v2::ThreadResumeParams, + response: v2::ThreadResumeResponse, + }, + #[serde(rename = "thread/archive")] + #[ts(rename = "thread/archive")] + ThreadArchive { + params: v2::ThreadArchiveParams, + response: v2::ThreadArchiveResponse, + }, + #[serde(rename = "thread/list")] + #[ts(rename = "thread/list")] + ThreadList { + params: v2::ThreadListParams, + response: v2::ThreadListResponse, + }, + #[serde(rename = "thread/compact")] + #[ts(rename = "thread/compact")] + ThreadCompact { + params: v2::ThreadCompactParams, + response: v2::ThreadCompactResponse, + }, + #[serde(rename = "turn/start")] + #[ts(rename = "turn/start")] + TurnStart { + params: v2::TurnStartParams, + response: v2::TurnStartResponse, + }, + #[serde(rename = "turn/interrupt")] + #[ts(rename = "turn/interrupt")] + TurnInterrupt { + params: v2::TurnInterruptParams, + response: v2::TurnInterruptResponse, + }, + + #[serde(rename = "model/list")] + #[ts(rename = "model/list")] + ModelList { + params: v2::ModelListParams, + response: v2::ModelListResponse, + }, + + #[serde(rename = "account/login/start")] + #[ts(rename = "account/login/start")] + LoginAccount { + params: v2::LoginAccountParams, + response: v2::LoginAccountResponse, + }, + + #[serde(rename = "account/login/cancel")] + #[ts(rename = "account/login/cancel")] + CancelLoginAccount { + params: v2::CancelLoginAccountParams, + response: v2::CancelLoginAccountResponse, + }, + + #[serde(rename = "account/logout")] + #[ts(rename = "account/logout")] + LogoutAccount { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + response: v2::LogoutAccountResponse, + }, + + #[serde(rename = "account/rateLimits/read")] + #[ts(rename = "account/rateLimits/read")] + GetAccountRateLimits { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + response: v2::GetAccountRateLimitsResponse, + }, + + #[serde(rename = "feedback/upload")] + #[ts(rename = "feedback/upload")] + FeedbackUpload { + params: v2::FeedbackUploadParams, + response: v2::FeedbackUploadResponse, + }, + + #[serde(rename = "account/read")] + #[ts(rename = "account/read")] + GetAccount { + params: v2::GetAccountParams, + response: v2::GetAccountResponse, + }, + + /// DEPRECATED APIs below + Initialize { + params: v1::InitializeParams, + response: v1::InitializeResponse, + }, + NewConversation { + params: v1::NewConversationParams, + response: v1::NewConversationResponse, + }, + GetConversationSummary { + params: v1::GetConversationSummaryParams, + response: v1::GetConversationSummaryResponse, + }, + /// List recorded Codex conversations (rollouts) with optional pagination and search. + ListConversations { + params: v1::ListConversationsParams, + response: v1::ListConversationsResponse, + }, + /// Resume a recorded Codex conversation from a rollout file. + ResumeConversation { + params: v1::ResumeConversationParams, + response: v1::ResumeConversationResponse, + }, + ArchiveConversation { + params: v1::ArchiveConversationParams, + response: v1::ArchiveConversationResponse, + }, + SendUserMessage { + params: v1::SendUserMessageParams, + response: v1::SendUserMessageResponse, + }, + SendUserTurn { + params: v1::SendUserTurnParams, + response: v1::SendUserTurnResponse, + }, + InterruptConversation { + params: v1::InterruptConversationParams, + response: v1::InterruptConversationResponse, + }, + AddConversationListener { + params: v1::AddConversationListenerParams, + response: v1::AddConversationSubscriptionResponse, + }, + RemoveConversationListener { + params: v1::RemoveConversationListenerParams, + response: v1::RemoveConversationSubscriptionResponse, + }, + GitDiffToRemote { + params: v1::GitDiffToRemoteParams, + response: v1::GitDiffToRemoteResponse, + }, + LoginApiKey { + params: v1::LoginApiKeyParams, + response: v1::LoginApiKeyResponse, + }, + LoginChatGpt { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + response: v1::LoginChatGptResponse, + }, + // DEPRECATED in favor of CancelLoginAccount + CancelLoginChatGpt { + params: v1::CancelLoginChatGptParams, + response: v1::CancelLoginChatGptResponse, + }, + LogoutChatGpt { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + response: v1::LogoutChatGptResponse, + }, + /// DEPRECATED in favor of GetAccount + GetAuthStatus { + params: v1::GetAuthStatusParams, + response: v1::GetAuthStatusResponse, + }, + GetUserSavedConfig { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + response: v1::GetUserSavedConfigResponse, + }, + SetDefaultModel { + params: v1::SetDefaultModelParams, + response: v1::SetDefaultModelResponse, + }, + GetUserAgent { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + response: v1::GetUserAgentResponse, + }, + UserInfo { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + response: v1::UserInfoResponse, + }, + FuzzyFileSearch { + params: FuzzyFileSearchParams, + response: FuzzyFileSearchResponse, + }, + /// Execute a command (argv vector) under the server's sandbox. + ExecOneOffCommand { + params: v1::ExecOneOffCommandParams, + response: v1::ExecOneOffCommandResponse, + }, +} + +/// Generates an `enum ServerRequest` where each variant is a request that the +/// server can send to the client along with the corresponding params and +/// response types. It also generates helper types used by the app/server +/// infrastructure (payload enum, request constructor, and export helpers). +macro_rules! server_request_definitions { + ( + $( + $(#[$variant_meta:meta])* + $variant:ident + ),* $(,)? + ) => { + paste! { + /// Request initiated from the server and sent to the client. + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] + #[serde(tag = "method", rename_all = "camelCase")] + pub enum ServerRequest { + $( + $(#[$variant_meta])* + $variant { + #[serde(rename = "id")] + request_id: RequestId, + params: [<$variant Params>], + }, + )* + } + + #[derive(Debug, Clone, PartialEq, JsonSchema)] + pub enum ServerRequestPayload { + $( $variant([<$variant Params>]), )* + } + + impl ServerRequestPayload { + pub fn request_with_id(self, request_id: RequestId) -> ServerRequest { + match self { + $(Self::$variant(params) => ServerRequest::$variant { request_id, params },)* + } + } + } + } + + pub fn export_server_responses( + out_dir: &::std::path::Path, + ) -> ::std::result::Result<(), ::ts_rs::ExportError> { + paste! { + $(<[<$variant Response>] as ::ts_rs::TS>::export_all_to(out_dir)?;)* + } + Ok(()) + } + + #[allow(clippy::vec_init_then_push)] + pub fn export_server_response_schemas( + out_dir: &Path, + ) -> ::anyhow::Result> { + let mut schemas = Vec::new(); + paste! { + $(schemas.push(crate::export::write_json_schema::<[<$variant Response>]>(out_dir, stringify!([<$variant Response>]))?);)* + } + Ok(schemas) + } + + #[allow(clippy::vec_init_then_push)] + pub fn export_server_param_schemas( + out_dir: &Path, + ) -> ::anyhow::Result> { + let mut schemas = Vec::new(); + paste! { + $(schemas.push(crate::export::write_json_schema::<[<$variant Params>]>(out_dir, stringify!([<$variant Params>]))?);)* + } + Ok(schemas) + } + }; +} + +/// Generates `ServerNotification` enum and helpers, including a JSON Schema +/// exporter for each notification. +macro_rules! server_notification_definitions { + ( + $( + $(#[$variant_meta:meta])* + $variant:ident $(=> $wire:literal)? ( $payload:ty ) + ),* $(,)? + ) => { + /// Notification sent from the server to the client. + #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS, Display)] + #[serde(tag = "method", content = "params", rename_all = "camelCase")] + #[strum(serialize_all = "camelCase")] + pub enum ServerNotification { + $( + $(#[$variant_meta])* + $(#[serde(rename = $wire)] #[ts(rename = $wire)] #[strum(serialize = $wire)])? + $variant($payload), + )* + } + + impl ServerNotification { + pub fn to_params(self) -> Result { + match self { + $(Self::$variant(params) => serde_json::to_value(params),)* + } + } + } + + impl TryFrom for ServerNotification { + type Error = serde_json::Error; + + fn try_from(value: JSONRPCNotification) -> Result { + serde_json::from_value(serde_json::to_value(value)?) + } + } + + #[allow(clippy::vec_init_then_push)] + pub fn export_server_notification_schemas( + out_dir: &::std::path::Path, + ) -> ::anyhow::Result> { + let mut schemas = Vec::new(); + $(schemas.push(crate::export::write_json_schema::<$payload>(out_dir, stringify!($payload))?);)* + Ok(schemas) + } + }; +} +/// Notifications sent from the client to the server. +macro_rules! client_notification_definitions { + ( + $( + $(#[$variant_meta:meta])* + $variant:ident $( ( $payload:ty ) )? + ),* $(,)? + ) => { + #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS, Display)] + #[serde(tag = "method", content = "params", rename_all = "camelCase")] + #[strum(serialize_all = "camelCase")] + pub enum ClientNotification { + $( + $(#[$variant_meta])* + $variant $( ( $payload ) )?, + )* + } + + pub fn export_client_notification_schemas( + _out_dir: &::std::path::Path, + ) -> ::anyhow::Result> { + let schemas = Vec::new(); + $( $(schemas.push(crate::export::write_json_schema::<$payload>(_out_dir, stringify!($payload))?);)? )* + Ok(schemas) + } + }; +} + +impl TryFrom for ServerRequest { + type Error = serde_json::Error; + + fn try_from(value: JSONRPCRequest) -> Result { + serde_json::from_value(serde_json::to_value(value)?) + } +} + +server_request_definitions! { + /// Request to approve a patch. + ApplyPatchApproval, + /// Request to exec a command. + ExecCommandApproval, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ApplyPatchApprovalParams { + pub conversation_id: ConversationId, + /// Use to correlate this with [codex_core::protocol::PatchApplyBeginEvent] + /// and [codex_core::protocol::PatchApplyEndEvent]. + pub call_id: String, + pub file_changes: HashMap, + /// Optional explanatory reason (e.g. request for extra write access). + pub reason: Option, + /// When set, the agent is asking the user to allow writes under this root + /// for the remainder of the session (unclear if this is honored today). + pub grant_root: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ExecCommandApprovalParams { + pub conversation_id: ConversationId, + /// Use to correlate this with [codex_core::protocol::ExecCommandBeginEvent] + /// and [codex_core::protocol::ExecCommandEndEvent]. + pub call_id: String, + pub command: Vec, + pub cwd: PathBuf, + pub reason: Option, + pub risk: Option, + pub parsed_cmd: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +pub struct ExecCommandApprovalResponse { + pub decision: ReviewDecision, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +pub struct ApplyPatchApprovalResponse { + pub decision: ReviewDecision, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase")] +pub struct FuzzyFileSearchParams { + pub query: String, + pub roots: Vec, + // if provided, will cancel any previous request that used the same value + pub cancellation_token: Option, +} + +/// Superset of [`codex_file_search::FileMatch`] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +pub struct FuzzyFileSearchResult { + pub root: String, + pub path: String, + pub file_name: String, + pub score: u32, + pub indices: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +pub struct FuzzyFileSearchResponse { + pub files: Vec, +} + +server_notification_definitions! { + /// NEW NOTIFICATIONS + ThreadStarted => "thread/started" (v2::ThreadStartedNotification), + TurnStarted => "turn/started" (v2::TurnStartedNotification), + TurnCompleted => "turn/completed" (v2::TurnCompletedNotification), + ItemStarted => "item/started" (v2::ItemStartedNotification), + ItemCompleted => "item/completed" (v2::ItemCompletedNotification), + AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification), + CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification), + McpToolCallProgress => "item/mcpToolCall/progress" (v2::McpToolCallProgressNotification), + AccountUpdated => "account/updated" (v2::AccountUpdatedNotification), + AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification), + + #[serde(rename = "account/login/completed")] + #[ts(rename = "account/login/completed")] + #[strum(serialize = "account/login/completed")] + AccountLoginCompleted(v2::AccountLoginCompletedNotification), + + /// DEPRECATED NOTIFICATIONS below + AuthStatusChange(v1::AuthStatusChangeNotification), + + /// Deprecated: use `account/login/completed` instead. + LoginChatGptComplete(v1::LoginChatGptCompleteNotification), + SessionConfigured(v1::SessionConfiguredNotification), +} + +client_notification_definitions! { + Initialized, +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Result; + use codex_protocol::account::PlanType; + use codex_protocol::protocol::AskForApproval; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn serialize_new_conversation() -> Result<()> { + let request = ClientRequest::NewConversation { + request_id: RequestId::Integer(42), + params: v1::NewConversationParams { + model: Some("gpt-5-codex".to_string()), + model_provider: None, + profile: None, + cwd: None, + approval_policy: Some(AskForApproval::OnRequest), + sandbox: None, + config: None, + base_instructions: None, + developer_instructions: None, + compact_prompt: None, + include_apply_patch_tool: None, + }, + }; + assert_eq!( + json!({ + "method": "newConversation", + "id": 42, + "params": { + "model": "gpt-5-codex", + "modelProvider": null, + "profile": null, + "cwd": null, + "approvalPolicy": "on-request", + "sandbox": null, + "config": null, + "baseInstructions": null, + "includeApplyPatchTool": null + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + + #[test] + fn conversation_id_serializes_as_plain_string() -> Result<()> { + let id = ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; + + assert_eq!( + json!("67e55044-10b1-426f-9247-bb680e5fe0c8"), + serde_json::to_value(id)? + ); + Ok(()) + } + + #[test] + fn conversation_id_deserializes_from_plain_string() -> Result<()> { + let id: ConversationId = + serde_json::from_value(json!("67e55044-10b1-426f-9247-bb680e5fe0c8"))?; + + assert_eq!( + ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?, + id, + ); + Ok(()) + } + + #[test] + fn serialize_client_notification() -> Result<()> { + let notification = ClientNotification::Initialized; + // Note there is no "params" field for this notification. + assert_eq!( + json!({ + "method": "initialized", + }), + serde_json::to_value(¬ification)?, + ); + Ok(()) + } + + #[test] + fn serialize_server_request() -> Result<()> { + let conversation_id = ConversationId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; + let params = ExecCommandApprovalParams { + conversation_id, + call_id: "call-42".to_string(), + command: vec!["echo".to_string(), "hello".to_string()], + cwd: PathBuf::from("/tmp"), + reason: Some("because tests".to_string()), + risk: None, + parsed_cmd: vec![ParsedCommand::Unknown { + cmd: "echo hello".to_string(), + }], + }; + let request = ServerRequest::ExecCommandApproval { + request_id: RequestId::Integer(7), + params: params.clone(), + }; + + assert_eq!( + json!({ + "method": "execCommandApproval", + "id": 7, + "params": { + "conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8", + "callId": "call-42", + "command": ["echo", "hello"], + "cwd": "/tmp", + "reason": "because tests", + "risk": null, + "parsedCmd": [ + { + "type": "unknown", + "cmd": "echo hello" + } + ] + } + }), + serde_json::to_value(&request)?, + ); + + let payload = ServerRequestPayload::ExecCommandApproval(params); + assert_eq!(payload.request_with_id(RequestId::Integer(7)), request); + Ok(()) + } + + #[test] + fn serialize_get_account_rate_limits() -> Result<()> { + let request = ClientRequest::GetAccountRateLimits { + request_id: RequestId::Integer(1), + params: None, + }; + assert_eq!( + json!({ + "method": "account/rateLimits/read", + "id": 1, + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + + #[test] + fn serialize_account_login_api_key() -> Result<()> { + let request = ClientRequest::LoginAccount { + request_id: RequestId::Integer(2), + params: v2::LoginAccountParams::ApiKey { + api_key: "secret".to_string(), + }, + }; + assert_eq!( + json!({ + "method": "account/login/start", + "id": 2, + "params": { + "type": "apiKey", + "apiKey": "secret" + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + + #[test] + fn serialize_account_login_chatgpt() -> Result<()> { + let request = ClientRequest::LoginAccount { + request_id: RequestId::Integer(3), + params: v2::LoginAccountParams::Chatgpt, + }; + assert_eq!( + json!({ + "method": "account/login/start", + "id": 3, + "params": { + "type": "chatgpt" + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + + #[test] + fn serialize_account_logout() -> Result<()> { + let request = ClientRequest::LogoutAccount { + request_id: RequestId::Integer(4), + params: None, + }; + assert_eq!( + json!({ + "method": "account/logout", + "id": 4, + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + + #[test] + fn serialize_get_account() -> Result<()> { + let request = ClientRequest::GetAccount { + request_id: RequestId::Integer(5), + params: v2::GetAccountParams { + refresh_token: false, + }, + }; + assert_eq!( + json!({ + "method": "account/read", + "id": 5, + "params": { + "refreshToken": false + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + + #[test] + fn account_serializes_fields_in_camel_case() -> Result<()> { + let api_key = v2::Account::ApiKey {}; + assert_eq!( + json!({ + "type": "apiKey", + }), + serde_json::to_value(&api_key)?, + ); + + let chatgpt = v2::Account::Chatgpt { + email: "user@example.com".to_string(), + plan_type: PlanType::Plus, + }; + assert_eq!( + json!({ + "type": "chatgpt", + "email": "user@example.com", + "planType": "plus", + }), + serde_json::to_value(&chatgpt)?, + ); + + Ok(()) + } + + #[test] + fn serialize_list_models() -> Result<()> { + let request = ClientRequest::ModelList { + request_id: RequestId::Integer(6), + params: v2::ModelListParams::default(), + }; + assert_eq!( + json!({ + "method": "model/list", + "id": 6, + "params": { + "limit": null, + "cursor": null + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } +} diff --git a/codex-rs/app-server-protocol/src/protocol/mod.rs b/codex-rs/app-server-protocol/src/protocol/mod.rs new file mode 100644 index 0000000000..11edf04cc1 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/mod.rs @@ -0,0 +1,6 @@ +// Module declarations for the app-server protocol namespace. +// Exposes protocol pieces used by `lib.rs` via `pub use protocol::common::*;`. + +pub mod common; +pub mod v1; +pub mod v2; diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs new file mode 100644 index 0000000000..d518abc1bd --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -0,0 +1,418 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use codex_protocol::ConversationId; +use codex_protocol::config_types::ForcedLoginMethod; +use codex_protocol::config_types::ReasoningEffort; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::SandboxMode; +use codex_protocol::config_types::Verbosity; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::TurnAbortReason; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; +use uuid::Uuid; + +// Reuse shared types defined in `common.rs`. +use crate::protocol::common::AuthMode; +use crate::protocol::common::GitSha; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct InitializeParams { + pub client_info: ClientInfo, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ClientInfo { + pub name: String, + pub title: Option, + pub version: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct InitializeResponse { + pub user_agent: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct NewConversationParams { + pub model: Option, + pub model_provider: Option, + pub profile: Option, + pub cwd: Option, + pub approval_policy: Option, + pub sandbox: Option, + pub config: Option>, + pub base_instructions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub developer_instructions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub compact_prompt: Option, + pub include_apply_patch_tool: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct NewConversationResponse { + pub conversation_id: ConversationId, + pub model: String, + pub reasoning_effort: Option, + pub rollout_path: PathBuf, +} + +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ResumeConversationResponse { + pub conversation_id: ConversationId, + pub model: String, + pub initial_messages: Option>, + pub rollout_path: PathBuf, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(untagged)] +pub enum GetConversationSummaryParams { + RolloutPath { + #[serde(rename = "rolloutPath")] + rollout_path: PathBuf, + }, + ConversationId { + #[serde(rename = "conversationId")] + conversation_id: ConversationId, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct GetConversationSummaryResponse { + pub summary: ConversationSummary, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ListConversationsParams { + pub page_size: Option, + pub cursor: Option, + pub model_providers: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ConversationSummary { + pub conversation_id: ConversationId, + pub path: PathBuf, + pub preview: String, + pub timestamp: Option, + pub model_provider: String, + pub cwd: PathBuf, + pub cli_version: String, + pub source: SessionSource, + pub git_info: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub struct ConversationGitInfo { + pub sha: Option, + pub branch: Option, + pub origin_url: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ListConversationsResponse { + pub items: Vec, + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ResumeConversationParams { + pub path: Option, + pub conversation_id: Option, + pub history: Option>, + pub overrides: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct AddConversationSubscriptionResponse { + #[schemars(with = "String")] + pub subscription_id: Uuid, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ArchiveConversationParams { + pub conversation_id: ConversationId, + pub rollout_path: PathBuf, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ArchiveConversationResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct RemoveConversationSubscriptionResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct LoginApiKeyParams { + pub api_key: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct LoginApiKeyResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct LoginChatGptResponse { + #[schemars(with = "String")] + pub login_id: Uuid, + pub auth_url: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct GitDiffToRemoteResponse { + pub sha: GitSha, + pub diff: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct CancelLoginChatGptParams { + #[schemars(with = "String")] + pub login_id: Uuid, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct GitDiffToRemoteParams { + pub cwd: PathBuf, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct CancelLoginChatGptResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct LogoutChatGptParams {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct LogoutChatGptResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct GetAuthStatusParams { + pub include_token: Option, + pub refresh_token: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ExecOneOffCommandParams { + pub command: Vec, + pub timeout_ms: Option, + pub cwd: Option, + pub sandbox_policy: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct ExecOneOffCommandResponse { + pub exit_code: i32, + pub stdout: String, + pub stderr: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct GetAuthStatusResponse { + pub auth_method: Option, + pub auth_token: Option, + pub requires_openai_auth: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct GetUserAgentResponse { + pub user_agent: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct UserInfoResponse { + pub alleged_user_email: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct GetUserSavedConfigResponse { + pub config: UserSavedConfig, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SetDefaultModelParams { + pub model: Option, + pub reasoning_effort: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SetDefaultModelResponse {} + +#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct UserSavedConfig { + pub approval_policy: Option, + pub sandbox_mode: Option, + pub sandbox_settings: Option, + pub forced_chatgpt_workspace_id: Option, + pub forced_login_method: Option, + pub model: Option, + pub model_reasoning_effort: Option, + pub model_reasoning_summary: Option, + pub model_verbosity: Option, + pub tools: Option, + pub profile: Option, + pub profiles: HashMap, +} + +#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct Profile { + pub model: Option, + pub model_provider: Option, + pub approval_policy: Option, + pub model_reasoning_effort: Option, + pub model_reasoning_summary: Option, + pub model_verbosity: Option, + pub chatgpt_base_url: Option, +} + +#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct Tools { + pub web_search: Option, + pub view_image: Option, +} + +#[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SandboxSettings { + #[serde(default)] + pub writable_roots: Vec, + pub network_access: Option, + pub exclude_tmpdir_env_var: Option, + pub exclude_slash_tmp: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SendUserMessageParams { + pub conversation_id: ConversationId, + pub items: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SendUserTurnParams { + pub conversation_id: ConversationId, + pub items: Vec, + pub cwd: PathBuf, + pub approval_policy: AskForApproval, + pub sandbox_policy: SandboxPolicy, + pub model: String, + pub effort: Option, + pub summary: ReasoningSummary, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SendUserTurnResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct InterruptConversationParams { + pub conversation_id: ConversationId, +} + +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct InterruptConversationResponse { + pub abort_reason: TurnAbortReason, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SendUserMessageResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct AddConversationListenerParams { + pub conversation_id: ConversationId, + #[serde(default)] + pub experimental_raw_events: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct RemoveConversationListenerParams { + #[schemars(with = "String")] + pub subscription_id: Uuid, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[serde(tag = "type", content = "data")] +pub enum InputItem { + Text { text: String }, + Image { image_url: String }, + LocalImage { path: PathBuf }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +/// Deprecated in favor of AccountLoginCompletedNotification. +pub struct LoginChatGptCompleteNotification { + #[schemars(with = "String")] + pub login_id: Uuid, + pub success: bool, + pub error: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +pub struct SessionConfiguredNotification { + pub session_id: ConversationId, + pub model: String, + pub reasoning_effort: Option, + pub history_log_id: u64, + #[ts(type = "number")] + pub history_entry_count: usize, + pub initial_messages: Option>, + pub rollout_path: PathBuf, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +/// Deprecated notification. Use AccountUpdatedNotification instead. +pub struct AuthStatusChangeNotification { + pub auth_method: Option, +} diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs new file mode 100644 index 0000000000..2afdb4575a --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -0,0 +1,710 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use crate::protocol::common::AuthMode; +use codex_protocol::ConversationId; +use codex_protocol::account::PlanType; +use codex_protocol::config_types::ReasoningEffort; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; +use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; +use codex_protocol::user_input::UserInput as CoreUserInput; +use mcp_types::ContentBlock as McpContentBlock; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value as JsonValue; +use ts_rs::TS; + +// Macro to declare a camelCased API v2 enum mirroring a core enum which +// tends to use kebab-case. +macro_rules! v2_enum_from_core { + ( + pub enum $Name:ident from $Src:path { $( $Variant:ident ),+ $(,)? } + ) => { + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] + #[serde(rename_all = "camelCase")] + #[ts(export_to = "v2/")] + pub enum $Name { $( $Variant ),+ } + + impl $Name { + pub fn to_core(self) -> $Src { + match self { $( $Name::$Variant => <$Src>::$Variant ),+ } + } + } + + impl From<$Src> for $Name { + fn from(value: $Src) -> Self { + match value { $( <$Src>::$Variant => $Name::$Variant ),+ } + } + } + }; +} + +v2_enum_from_core!( + pub enum AskForApproval from codex_protocol::protocol::AskForApproval { + UnlessTrusted, OnFailure, OnRequest, Never + } +); + +v2_enum_from_core!( + pub enum SandboxMode from codex_protocol::config_types::SandboxMode { + ReadOnly, WorkspaceWrite, DangerFullAccess + } +); + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "mode", rename_all = "camelCase")] +#[ts(tag = "mode")] +#[ts(export_to = "v2/")] +pub enum SandboxPolicy { + DangerFullAccess, + ReadOnly, + WorkspaceWrite { + #[serde(default)] + writable_roots: Vec, + #[serde(default)] + network_access: bool, + #[serde(default)] + exclude_tmpdir_env_var: bool, + #[serde(default)] + exclude_slash_tmp: bool, + }, +} + +impl SandboxPolicy { + pub fn to_core(&self) -> codex_protocol::protocol::SandboxPolicy { + match self { + SandboxPolicy::DangerFullAccess => { + codex_protocol::protocol::SandboxPolicy::DangerFullAccess + } + SandboxPolicy::ReadOnly => codex_protocol::protocol::SandboxPolicy::ReadOnly, + SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + } => codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { + writable_roots: writable_roots.clone(), + network_access: *network_access, + exclude_tmpdir_env_var: *exclude_tmpdir_env_var, + exclude_slash_tmp: *exclude_slash_tmp, + }, + } + } +} + +impl From for SandboxPolicy { + fn from(value: codex_protocol::protocol::SandboxPolicy) -> Self { + match value { + codex_protocol::protocol::SandboxPolicy::DangerFullAccess => { + SandboxPolicy::DangerFullAccess + } + codex_protocol::protocol::SandboxPolicy::ReadOnly => SandboxPolicy::ReadOnly, + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + } => SandboxPolicy::WorkspaceWrite { + writable_roots, + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum Account { + #[serde(rename = "apiKey", rename_all = "camelCase")] + #[ts(rename = "apiKey", rename_all = "camelCase")] + ApiKey {}, + + #[serde(rename = "chatgpt", rename_all = "camelCase")] + #[ts(rename = "chatgpt", rename_all = "camelCase")] + Chatgpt { email: String, plan_type: PlanType }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum LoginAccountParams { + #[serde(rename = "apiKey", rename_all = "camelCase")] + #[ts(rename = "apiKey", rename_all = "camelCase")] + ApiKey { + #[serde(rename = "apiKey")] + #[ts(rename = "apiKey")] + api_key: String, + }, + #[serde(rename = "chatgpt")] + #[ts(rename = "chatgpt")] + Chatgpt, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum LoginAccountResponse { + #[serde(rename = "apiKey", rename_all = "camelCase")] + #[ts(rename = "apiKey", rename_all = "camelCase")] + ApiKey {}, + #[serde(rename = "chatgpt", rename_all = "camelCase")] + #[ts(rename = "chatgpt", rename_all = "camelCase")] + Chatgpt { + // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. + // Convert to/from UUIDs at the application layer as needed. + login_id: String, + /// URL the client should open in a browser to initiate the OAuth flow. + auth_url: String, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CancelLoginAccountParams { + pub login_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CancelLoginAccountResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct LogoutAccountResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountRateLimitsResponse { + pub rate_limits: RateLimitSnapshot, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountParams { + #[serde(default)] + pub refresh_token: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetAccountResponse { + pub account: Option, + pub requires_openai_auth: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelListParams { + /// Opaque pagination cursor returned by a previous call. + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + pub limit: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Model { + pub id: String, + pub model: String, + pub display_name: String, + pub description: String, + pub supported_reasoning_efforts: Vec, + pub default_reasoning_effort: ReasoningEffort, + // Only one model should be marked as default. + pub is_default: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ReasoningEffortOption { + pub reasoning_effort: ReasoningEffort, + pub description: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// If None, there are no more items to return. + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FeedbackUploadParams { + pub classification: String, + pub reason: Option, + pub conversation_id: Option, + pub include_logs: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FeedbackUploadResponse { + pub thread_id: String, +} + +// === Threads, Turns, and Items === +// Thread APIs +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStartParams { + pub model: Option, + pub model_provider: Option, + pub cwd: Option, + pub approval_policy: Option, + pub sandbox: Option, + pub config: Option>, + pub base_instructions: Option, + pub developer_instructions: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStartResponse { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadResumeParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadResumeResponse { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadArchiveParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadArchiveResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadListParams { + /// Opaque pagination cursor returned by a previous call. + pub cursor: Option, + /// Optional page size; defaults to a reasonable server-side value. + pub limit: Option, + /// Optional provider filter; when set, only sessions recorded under these + /// providers are returned. When present but empty, includes all providers. + pub model_providers: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadListResponse { + pub data: Vec, + /// Opaque cursor to pass to the next call to continue after the last item. + /// if None, there are no more items to return. + pub next_cursor: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadCompactParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadCompactResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Thread { + pub id: String, + /// Usually the first user message in the thread, if available. + pub preview: String, + pub model_provider: String, + /// Unix timestamp (in seconds) when the thread was created. + pub created_at: i64, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountUpdatedNotification { + pub auth_mode: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Turn { + pub id: String, + pub items: Vec, + pub status: TurnStatus, + pub error: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnError { + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum TurnStatus { + Completed, + Interrupted, + Failed, + InProgress, +} + +// Turn APIs +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnStartParams { + pub thread_id: String, + pub input: Vec, + /// Override the working directory for this turn and subsequent turns. + pub cwd: Option, + /// Override the approval policy for this turn and subsequent turns. + pub approval_policy: Option, + /// Override the sandbox policy for this turn and subsequent turns. + pub sandbox_policy: Option, + /// Override the model for this turn and subsequent turns. + pub model: Option, + /// Override the reasoning effort for this turn and subsequent turns. + pub effort: Option, + /// Override the reasoning summary for this turn and subsequent turns. + pub summary: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnStartResponse { + pub turn: Turn, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnInterruptParams { + pub thread_id: String, + pub turn_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnInterruptResponse {} + +// User input types +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum UserInput { + Text { text: String }, + Image { url: String }, + LocalImage { path: PathBuf }, +} + +impl UserInput { + pub fn into_core(self) -> CoreUserInput { + match self { + UserInput::Text { text } => CoreUserInput::Text { text }, + UserInput::Image { url } => CoreUserInput::Image { image_url: url }, + UserInput::LocalImage { path } => CoreUserInput::LocalImage { path }, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase")] +#[ts(tag = "type")] +#[ts(export_to = "v2/")] +pub enum ThreadItem { + UserMessage { + id: String, + content: Vec, + }, + AgentMessage { + id: String, + text: String, + }, + Reasoning { + id: String, + text: String, + }, + CommandExecution { + id: String, + command: String, + aggregated_output: String, + exit_code: Option, + status: CommandExecutionStatus, + duration_ms: Option, + }, + FileChange { + id: String, + changes: Vec, + status: PatchApplyStatus, + }, + McpToolCall { + id: String, + server: String, + tool: String, + status: McpToolCallStatus, + arguments: JsonValue, + result: Option, + error: Option, + }, + WebSearch { + id: String, + query: String, + }, + TodoList { + id: String, + items: Vec, + }, + ImageView { + id: String, + path: String, + }, + CodeReview { + id: String, + review: String, + }, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum CommandExecutionStatus { + InProgress, + Completed, + Failed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FileUpdateChange { + pub path: String, + pub kind: PatchChangeKind, + pub diff: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum PatchChangeKind { + Add, + Delete, + Update, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum PatchApplyStatus { + Completed, + Failed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub enum McpToolCallStatus { + InProgress, + Completed, + Failed, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpToolCallResult { + pub content: Vec, + pub structured_content: JsonValue, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpToolCallError { + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TodoItem { + pub id: String, + pub text: String, + pub completed: bool, +} + +// === Server Notifications === +// Thread/Turn lifecycle notifications and item progress events +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadStartedNotification { + pub thread: Thread, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnStartedNotification { + pub turn: Turn, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct Usage { + pub input_tokens: i32, + pub cached_input_tokens: i32, + pub output_tokens: i32, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct TurnCompletedNotification { + pub turn: Turn, + // TODO: should usage be stored on the Turn object, and we return that instead? + pub usage: Usage, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ItemStartedNotification { + pub item: ThreadItem, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ItemCompletedNotification { + pub item: ThreadItem, +} + +// Item-specific progress notifications +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AgentMessageDeltaNotification { + pub item_id: String, + pub delta: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct CommandExecutionOutputDeltaNotification { + pub item_id: String, + pub delta: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct McpToolCallProgressNotification { + pub item_id: String, + pub message: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountRateLimitsUpdatedNotification { + pub rate_limits: RateLimitSnapshot, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RateLimitSnapshot { + pub primary: Option, + pub secondary: Option, +} + +impl From for RateLimitSnapshot { + fn from(value: CoreRateLimitSnapshot) -> Self { + Self { + primary: value.primary.map(RateLimitWindow::from), + secondary: value.secondary.map(RateLimitWindow::from), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RateLimitWindow { + pub used_percent: i32, + pub window_duration_mins: Option, + pub resets_at: Option, +} + +impl From for RateLimitWindow { + fn from(value: CoreRateLimitWindow) -> Self { + Self { + used_percent: value.used_percent.round() as i32, + window_duration_mins: value.window_minutes, + resets_at: value.resets_at, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AccountLoginCompletedNotification { + // Use plain String for identifiers to avoid TS/JSON Schema quirks around uuid-specific types. + // Convert to/from UUIDs at the application layer as needed. + pub login_id: Option, + pub success: bool, + pub error: Option, +} diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 545ef4898c..d693e7bb7f 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -19,11 +19,14 @@ anyhow = { workspace = true } codex-arg0 = { workspace = true } codex-common = { workspace = true, features = ["cli"] } codex-core = { workspace = true } +codex-backend-client = { workspace = true } codex-file-search = { workspace = true } codex-login = { workspace = true } codex-protocol = { workspace = true } codex-app-server-protocol = { workspace = true } +codex-feedback = { workspace = true } codex-utils-json-to-toml = { workspace = true } +chrono = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = [ @@ -35,6 +38,7 @@ tokio = { workspace = true, features = [ ] } tracing = { workspace = true, features = ["log"] } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } +opentelemetry-appender-tracing = { workspace = true } uuid = { workspace = true, features = ["serde", "v7"] } [dev-dependencies] @@ -44,6 +48,7 @@ base64 = { workspace = true } core_test_support = { workspace = true } os_info = { workspace = true } pretty_assertions = { workspace = true } +serial_test = { workspace = true } tempfile = { workspace = true } toml = { workspace = true } wiremock = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 2eb8a1ad72..b2d6c25e8b 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1,6 +1,6 @@ # codex-app-server -`codex app-server` is the harness Codex uses to power rich interfaces such as the [Codex VS Code extension](https://marketplace.visualstudio.com/items?itemName=openai.chatgpt). The message schema is currently unstable, but those who wish to build experimental UIs on top of Codex may find it valuable. +`codex app-server` is the interface Codex uses to power rich interfaces such as the [Codex VS Code extension](https://marketplace.visualstudio.com/items?itemName=openai.chatgpt). The message schema is currently unstable, but those who wish to build experimental UIs on top of Codex may find it valuable. ## Protocol @@ -13,3 +13,241 @@ Currently, you can dump a TypeScript version of the schema using `codex generate ``` codex generate-ts --out DIR ``` + +## Initialization + +Clients must send a single `initialize` request before invoking any other method, then acknowledge with an `initialized` notification. The server returns the user agent string it will present to upstream services; subsequent requests issued before initialization receive a `"Not initialized"` error, and repeated `initialize` calls receive an `"Already initialized"` error. + +Example: + +```json +{ "method": "initialize", "id": 0, "params": { + "clientInfo": { "name": "codex-vscode", "title": "Codex VS Code Extension", "version": "0.1.0" } +} } +{ "id": 0, "result": { "userAgent": "codex-app-server/0.1.0 codex-vscode/0.1.0" } } +{ "method": "initialized" } +``` + +## Core primitives + +We have 3 top level primitives: +- Thread - a conversation between the Codex agent and a user. Each thread contains multiple turns. +- Turn - one turn of the conversation, typically starting with a user message and finishing with an agent message. Each turn contains multiple items. +- Item - represents user inputs and agent outputs as part of the turn, persisted and used as the context for future conversations. + +## Thread & turn endpoints + +The JSON-RPC API exposes dedicated methods for managing Codex conversations. Threads store long-lived conversation metadata, and turns store the per-message exchange (input → Codex output, including streamed items). Use the thread APIs to create, list, or archive sessions, then drive the conversation with turn APIs and notifications. + +### Quick reference +- `thread/start` — create a new thread; emits `thread/started` and auto-subscribes you to turn/item events for that thread. +- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. +- `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders` filtering. +- `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success. +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. +- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. + +### 1) Start or resume a thread + +Start a fresh thread when you need a new Codex conversation. Optional fields mirror CLI defaults: set `model`, `modelProvider`, `cwd`, `approvalPolicy`, `sandbox`, or custom `config` values. Instructions can be set via `baseInstructions` and `developerInstructions`: + +```json +{ "method": "thread/start", "id": 10, "params": { + "model": "gpt-5-codex", + "cwd": "/Users/me/project", + "approvalPolicy": "never", + "sandbox": "workspace-write", + "baseInstructions": "You're helping with refactors." +} } +{ "id": 10, "result": { + "thread": { + "id": "thr_123", + "preview": "", + "modelProvider": "openai", + "createdAt": 1730910000 + } +} } +{ "method": "thread/started", "params": { "thread": { … } } } +``` + +To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`, and no additional notifications are emitted: + +```json +{ "method": "thread/resume", "id": 11, "params": { "threadId": "thr_123" } } +{ "id": 11, "result": { "thread": { "id": "thr_123", … } } } +``` + +### 2) List threads (pagination & filters) + +`thread/list` lets you render a history UI. Pass any combination of: +- `cursor` — opaque string from a prior response; omit for the first page. +- `limit` — server defaults to a reasonable page size if unset. +- `modelProviders` — restrict results to specific providers; unset, null, or an empty array will include all providers. + +Example: + +```json +{ "method": "thread/list", "id": 20, "params": { + "cursor": null, + "limit": 25, + "modelProviders": ["openai"] +} } +{ "id": 20, "result": { + "data": [ + { "id": "thr_a", "preview": "Create a TUI", "modelProvider": "openai", "createdAt": 1730831111 }, + { "id": "thr_b", "preview": "Fix tests", "modelProvider": "openai", "createdAt": 1730750000 } + ], + "nextCursor": "opaque-token-or-null" +} } +``` + +When `nextCursor` is `null`, you’ve reached the final page. + +### 3) Archive a thread + +Use `thread/archive` to move the persisted rollout (stored as a JSONL file on disk) into the archived sessions directory. + +```json +{ "method": "thread/archive", "id": 21, "params": { "threadId": "thr_b" } } +{ "id": 21, "result": {} } +``` + +An archived thread will not appear in future calls to `thread/list`. + +### 4) Start a turn (send user input) + +Turns attach user input (text or images) to a thread and trigger Codex generation. The `input` field is a list of discriminated unions: + +- `{"type":"text","text":"Explain this diff"}` +- `{"type":"image","url":"https://…png"}` +- `{"type":"localImage","path":"/tmp/screenshot.png"}` + +Override knobs apply to the new turn and become the defaults for subsequent turns on the same thread: + +```json +{ "method": "turn/start", "id": 30, "params": { + "threadId": "thr_123", + "input": [ { "type": "text", "text": "Run tests" } ], + "cwd": "/Users/me/project", + "approvalPolicy": "untrusted", + "sandboxPolicy": "workspace-write", + "model": "gpt-5-codex", + "effort": "medium", + "summary": "focus-on-test-failures" +} } +{ "id": 30, "result": { "turn": { + "id": "turn_456", + "status": "inProgress", + "items": [], + "error": null +} } } +``` + +### 5) Interrupt an active turn + +You can cancel a running Turn with `turn/interrupt`. + +```json +{ "method": "turn/interrupt", "id": 31, "params": { + "threadId": "thr_123", + "turnId": "turn_456" +} } +{ "id": 31, "result": {} } +``` + +The server requests cancellations for running subprocesses, then emits a `turn/completed` event with `status: "interrupted"`. Rely on the `turn/completed` to know when Codex-side cleanup is done. + +## Auth endpoints + +The v2 JSON-RPC auth/account surface exposes request/response methods plus server-initiated notifications (no `id`). Use these to determine auth state, start or cancel logins, logout, and inspect ChatGPT rate limits. + +### Quick reference +- `account/read` — fetch current account info; optionally refresh tokens. +- `account/login/start` — begin login (`apiKey` or `chatgpt`). +- `account/login/completed` (notify) — emitted when a login attempt finishes (success or error). +- `account/login/cancel` — cancel a pending ChatGPT login by `loginId`. +- `account/logout` — sign out; triggers `account/updated`. +- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`). +- `account/rateLimits/read` — fetch ChatGPT rate limits; updates arrive via `account/rateLimits/updated` (notify). + +### 1) Check auth state + +Request: +```json +{ "method": "account/read", "id": 1, "params": { "refreshToken": false } } +``` + +Response examples: +```json +{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": false } } // No OpenAI auth needed (e.g., OSS/local models) +{ "id": 1, "result": { "account": null, "requiresOpenaiAuth": true } } // OpenAI auth required (typical for OpenAI-hosted models) +{ "id": 1, "result": { "account": { "type": "apiKey" }, "requiresOpenaiAuth": true } } +{ "id": 1, "result": { "account": { "type": "chatgpt", "email": "user@example.com", "planType": "pro" }, "requiresOpenaiAuth": true } } +``` + +Field notes: +- `refreshToken` (bool): set `true` to force a token refresh. +- `requiresOpenaiAuth` reflects the active provider; when `false`, Codex can run without OpenAI credentials. + +### 2) Log in with an API key + +1. Send: + ```json + { "method": "account/login/start", "id": 2, "params": { "type": "apiKey", "apiKey": "sk-…" } } + ``` +2. Expect: + ```json + { "id": 2, "result": { "type": "apiKey" } } + ``` +3. Notifications: + ```json + { "method": "account/login/completed", "params": { "loginId": null, "success": true, "error": null } } + { "method": "account/updated", "params": { "authMode": "apikey" } } + ``` + +### 3) Log in with ChatGPT (browser flow) + +1. Start: + ```json + { "method": "account/login/start", "id": 3, "params": { "type": "chatgpt" } } + { "id": 3, "result": { "type": "chatgpt", "loginId": "", "authUrl": "https://chatgpt.com/…&redirect_uri=http%3A%2F%2Flocalhost%3A%2Fauth%2Fcallback" } } + ``` +2. Open `authUrl` in a browser; the app-server hosts the local callback. +3. Wait for notifications: + ```json + { "method": "account/login/completed", "params": { "loginId": "", "success": true, "error": null } } + { "method": "account/updated", "params": { "authMode": "chatgpt" } } + ``` + +### 4) Cancel a ChatGPT login + +```json +{ "method": "account/login/cancel", "id": 4, "params": { "loginId": "" } } +{ "method": "account/login/completed", "params": { "loginId": "", "success": false, "error": "…" } } +``` + +### 5) Logout + +```json +{ "method": "account/logout", "id": 5 } +{ "id": 5, "result": {} } +{ "method": "account/updated", "params": { "authMode": null } } +``` + +### 6) Rate limits (ChatGPT) + +```json +{ "method": "account/rateLimits/read", "id": 6 } +{ "id": 6, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null } } } +{ "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } } +``` + +Field notes: +- `usedPercent` is current usage within the OpenAI quota window. +- `windowDurationMins` is the quota window length. +- `resetsAt` is a Unix timestamp (seconds) for the next reset. + +### Dev notes + +- `codex generate-ts --out ` emits v2 types under `v2/`. +- See [“Authentication and authorization” in the config docs](../../docs/config.md#authentication-and-authorization) for configuration knobs. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index f455ad32e1..12bbdf71fb 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1,23 +1,45 @@ use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::fuzzy_file_search::run_fuzzy_file_search; +use crate::models::supported_models; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::OutgoingNotification; +use chrono::DateTime; +use chrono::Utc; +use codex_app_server_protocol::Account; +use codex_app_server_protocol::AccountLoginCompletedNotification; +use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; +use codex_app_server_protocol::AccountUpdatedNotification; use codex_app_server_protocol::AddConversationListenerParams; use codex_app_server_protocol::AddConversationSubscriptionResponse; use codex_app_server_protocol::ApplyPatchApprovalParams; use codex_app_server_protocol::ApplyPatchApprovalResponse; use codex_app_server_protocol::ArchiveConversationParams; use codex_app_server_protocol::ArchiveConversationResponse; +use codex_app_server_protocol::AskForApproval; +use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::AuthStatusChangeNotification; +use codex_app_server_protocol::CancelLoginAccountParams; +use codex_app_server_protocol::CancelLoginAccountResponse; +use codex_app_server_protocol::CancelLoginChatGptResponse; use codex_app_server_protocol::ClientRequest; +use codex_app_server_protocol::ConversationGitInfo; use codex_app_server_protocol::ConversationSummary; use codex_app_server_protocol::ExecCommandApprovalParams; use codex_app_server_protocol::ExecCommandApprovalResponse; use codex_app_server_protocol::ExecOneOffCommandParams; use codex_app_server_protocol::ExecOneOffCommandResponse; +use codex_app_server_protocol::FeedbackUploadParams; +use codex_app_server_protocol::FeedbackUploadResponse; use codex_app_server_protocol::FuzzyFileSearchParams; use codex_app_server_protocol::FuzzyFileSearchResponse; +use codex_app_server_protocol::GetAccountParams; +use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_app_server_protocol::GetAccountResponse; +use codex_app_server_protocol::GetAuthStatusParams; +use codex_app_server_protocol::GetAuthStatusResponse; +use codex_app_server_protocol::GetConversationSummaryParams; +use codex_app_server_protocol::GetConversationSummaryResponse; use codex_app_server_protocol::GetUserAgentResponse; use codex_app_server_protocol::GetUserSavedConfigResponse; use codex_app_server_protocol::GitDiffToRemoteResponse; @@ -27,10 +49,15 @@ use codex_app_server_protocol::InterruptConversationResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::ListConversationsParams; use codex_app_server_protocol::ListConversationsResponse; +use codex_app_server_protocol::LoginAccountParams; use codex_app_server_protocol::LoginApiKeyParams; use codex_app_server_protocol::LoginApiKeyResponse; use codex_app_server_protocol::LoginChatGptCompleteNotification; use codex_app_server_protocol::LoginChatGptResponse; +use codex_app_server_protocol::LogoutAccountResponse; +use codex_app_server_protocol::LogoutChatGptResponse; +use codex_app_server_protocol::ModelListParams; +use codex_app_server_protocol::ModelListResponse; use codex_app_server_protocol::NewConversationParams; use codex_app_server_protocol::NewConversationResponse; use codex_app_server_protocol::RemoveConversationListenerParams; @@ -38,6 +65,8 @@ use codex_app_server_protocol::RemoveConversationSubscriptionResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::Result as JsonRpcResult; use codex_app_server_protocol::ResumeConversationParams; +use codex_app_server_protocol::ResumeConversationResponse; +use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::SendUserMessageParams; use codex_app_server_protocol::SendUserMessageResponse; use codex_app_server_protocol::SendUserTurnParams; @@ -47,50 +76,77 @@ use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::SessionConfiguredNotification; use codex_app_server_protocol::SetDefaultModelParams; use codex_app_server_protocol::SetDefaultModelResponse; +use codex_app_server_protocol::Thread; +use codex_app_server_protocol::ThreadArchiveParams; +use codex_app_server_protocol::ThreadArchiveResponse; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStartedNotification; +use codex_app_server_protocol::Turn; +use codex_app_server_protocol::TurnInterruptParams; +use codex_app_server_protocol::TurnInterruptResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInfoResponse; +use codex_app_server_protocol::UserInput as V2UserInput; use codex_app_server_protocol::UserSavedConfig; +use codex_backend_client::Client as BackendClient; use codex_core::AuthManager; use codex_core::CodexConversation; use codex_core::ConversationManager; use codex_core::Cursor as RolloutCursor; use codex_core::INTERACTIVE_SESSION_SOURCES; +use codex_core::InitialHistory; use codex_core::NewConversation; use codex_core::RolloutRecorder; use codex_core::SessionMeta; use codex_core::auth::CLIENT_ID; -use codex_core::auth::get_auth_file; use codex_core::auth::login_with_api_key; -use codex_core::auth::try_read_auth_json; use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; -use codex_core::config::load_config_as_toml; -use codex_core::config_edit::CONFIG_KEY_EFFORT; -use codex_core::config_edit::CONFIG_KEY_MODEL; -use codex_core::config_edit::persist_overrides_and_clear_if_none; +use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::config_loader::load_config_as_toml; use codex_core::default_client::get_codex_user_agent; use codex_core::exec::ExecParams; use codex_core::exec_env::create_env; +use codex_core::find_conversation_path_by_id_str; use codex_core::get_platform_sandbox; use codex_core::git_info::git_diff_to_remote; +use codex_core::parse_cursor; use codex_core::protocol::ApplyPatchApprovalRequestEvent; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; -use codex_core::protocol::InputItem as CoreInputItem; use codex_core::protocol::Op; use codex_core::protocol::ReviewDecision; +use codex_core::read_head_for_summary; +use codex_feedback::CodexFeedback; use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; use codex_login::run_login_server; use codex_protocol::ConversationId; -use codex_protocol::models::ContentItem; +use codex_protocol::config_types::ForcedLoginMethod; +use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; -use codex_protocol::protocol::InputMessageKind; +use codex_protocol::protocol::GitInfo; +use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::USER_MESSAGE_BEGIN; +use codex_protocol::user_input::UserInput as CoreInputItem; use codex_utils_json_to_toml::json_to_toml; use std::collections::HashMap; use std::ffi::OsStr; +use std::io::Error as IoError; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -104,9 +160,11 @@ use tracing::info; use tracing::warn; use uuid::Uuid; +type PendingInterruptQueue = Vec<(RequestId, ApiVersion)>; +type PendingInterrupts = Arc>>; + // Duration before a ChatGPT login attempt is abandoned. const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); - struct ActiveLogin { shutdown_handle: ShutdownHandle, login_id: Uuid, @@ -128,8 +186,15 @@ pub(crate) struct CodexMessageProcessor { conversation_listeners: HashMap>, active_login: Arc>>, // Queue of pending interrupt requests per conversation. We reply when TurnAborted arrives. - pending_interrupts: Arc>>>, + pending_interrupts: PendingInterrupts, pending_fuzzy_searches: Arc>>>, + feedback: CodexFeedback, +} + +#[derive(Clone, Copy, Debug)] +enum ApiVersion { + V1, + V2, } impl CodexMessageProcessor { @@ -139,6 +204,7 @@ impl CodexMessageProcessor { outgoing: Arc, codex_linux_sandbox_exe: Option, config: Arc, + feedback: CodexFeedback, ) -> Self { Self { auth_manager, @@ -150,6 +216,7 @@ impl CodexMessageProcessor { active_login: Arc::new(Mutex::new(None)), pending_interrupts: Arc::new(Mutex::new(HashMap::new())), pending_fuzzy_searches: Arc::new(Mutex::new(HashMap::new())), + feedback, } } @@ -158,15 +225,62 @@ impl CodexMessageProcessor { ClientRequest::Initialize { .. } => { panic!("Initialize should be handled in MessageProcessor"); } + // === v2 Thread/Turn APIs === + ClientRequest::ThreadStart { request_id, params } => { + self.thread_start(request_id, params).await; + } + ClientRequest::ThreadResume { request_id, params } => { + self.thread_resume(request_id, params).await; + } + ClientRequest::ThreadArchive { request_id, params } => { + self.thread_archive(request_id, params).await; + } + ClientRequest::ThreadList { request_id, params } => { + self.thread_list(request_id, params).await; + } + ClientRequest::ThreadCompact { + request_id, + params: _, + } => { + self.send_unimplemented_error(request_id, "thread/compact") + .await; + } + ClientRequest::TurnStart { request_id, params } => { + self.turn_start(request_id, params).await; + } + ClientRequest::TurnInterrupt { request_id, params } => { + self.turn_interrupt(request_id, params).await; + } ClientRequest::NewConversation { request_id, params } => { // Do not tokio::spawn() to process new_conversation() // asynchronously because we need to ensure the conversation is // created before processing any subsequent messages. self.process_new_conversation(request_id, params).await; } + ClientRequest::GetConversationSummary { request_id, params } => { + self.get_conversation_summary(request_id, params).await; + } ClientRequest::ListConversations { request_id, params } => { self.handle_list_conversations(request_id, params).await; } + ClientRequest::ModelList { request_id, params } => { + self.list_models(request_id, params).await; + } + ClientRequest::LoginAccount { request_id, params } => { + self.login_v2(request_id, params).await; + } + ClientRequest::LogoutAccount { + request_id, + params: _, + } => { + self.logout_v2(request_id).await; + } + ClientRequest::CancelLoginAccount { request_id, params } => { + self.cancel_login_v2(request_id, params).await; + } + ClientRequest::GetAccount { request_id, params } => { + self.get_account(request_id, params).await; + } ClientRequest::ResumeConversation { request_id, params } => { self.handle_resume_conversation(request_id, params).await; } @@ -192,13 +306,13 @@ impl CodexMessageProcessor { self.git_diff_to_origin(request_id, params.cwd).await; } ClientRequest::LoginApiKey { request_id, params } => { - self.login_api_key(request_id, params).await; + self.login_api_key_v1(request_id, params).await; } ClientRequest::LoginChatGpt { request_id, params: _, } => { - self.login_chatgpt(request_id).await; + self.login_chatgpt_v1(request_id).await; } ClientRequest::CancelLoginChatGpt { request_id, params } => { self.cancel_login_chatgpt(request_id, params.login_id).await; @@ -207,7 +321,7 @@ impl CodexMessageProcessor { request_id, params: _, } => { - self.logout_chatgpt(request_id).await; + self.logout_v1(request_id).await; } ClientRequest::GetAuthStatus { request_id, params } => { self.get_auth_status(request_id, params).await; @@ -239,10 +353,55 @@ impl CodexMessageProcessor { ClientRequest::ExecOneOffCommand { request_id, params } => { self.exec_one_off_command(request_id, params).await; } + ClientRequest::GetAccountRateLimits { + request_id, + params: _, + } => { + self.get_account_rate_limits(request_id).await; + } + ClientRequest::FeedbackUpload { request_id, params } => { + self.upload_feedback(request_id, params).await; + } } } - async fn login_api_key(&mut self, request_id: RequestId, params: LoginApiKeyParams) { + async fn send_unimplemented_error(&self, request_id: RequestId, method: &str) { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("{method} is not implemented yet"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + + async fn login_v2(&mut self, request_id: RequestId, params: LoginAccountParams) { + match params { + LoginAccountParams::ApiKey { api_key } => { + self.login_api_key_v2(request_id, LoginApiKeyParams { api_key }) + .await; + } + LoginAccountParams::Chatgpt => { + self.login_chatgpt_v2(request_id).await; + } + } + } + + async fn login_api_key_common( + &mut self, + params: &LoginApiKeyParams, + ) -> std::result::Result<(), JSONRPCErrorError> { + if matches!( + self.config.forced_login_method, + Some(ForcedLoginMethod::Chatgpt) + ) { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "API key login is disabled. Use ChatGPT login instead.".to_string(), + data: None, + }); + } + + // Cancel any active login attempt. { let mut guard = self.active_login.lock().await; if let Some(active) = guard.take() { @@ -250,9 +409,26 @@ impl CodexMessageProcessor { } } - match login_with_api_key(&self.config.codex_home, ¶ms.api_key) { + match login_with_api_key( + &self.config.codex_home, + ¶ms.api_key, + self.config.cli_auth_credentials_store_mode, + ) { Ok(()) => { self.auth_manager.reload(); + Ok(()) + } + Err(err) => Err(JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to save api key: {err}"), + data: None, + }), + } + } + + async fn login_api_key_v1(&mut self, request_id: RequestId, params: LoginApiKeyParams) { + match self.login_api_key_common(¶ms).await { + Ok(()) => { self.outgoing .send_response(request_id, LoginApiKeyResponse {}) .await; @@ -264,10 +440,300 @@ impl CodexMessageProcessor { .send_server_notification(ServerNotification::AuthStatusChange(payload)) .await; } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn login_api_key_v2(&mut self, request_id: RequestId, params: LoginApiKeyParams) { + match self.login_api_key_common(¶ms).await { + Ok(()) => { + let response = codex_app_server_protocol::LoginAccountResponse::ApiKey {}; + self.outgoing.send_response(request_id, response).await; + + let payload_login_completed = AccountLoginCompletedNotification { + login_id: None, + success: true, + error: None, + }; + self.outgoing + .send_server_notification(ServerNotification::AccountLoginCompleted( + payload_login_completed, + )) + .await; + + let payload_v2 = AccountUpdatedNotification { + auth_mode: self.auth_manager.auth().map(|auth| auth.mode), + }; + self.outgoing + .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) + .await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } + } + + // Build options for a ChatGPT login attempt; performs validation. + async fn login_chatgpt_common( + &self, + ) -> std::result::Result { + let config = self.config.as_ref(); + + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "ChatGPT login is disabled. Use API key login instead.".to_string(), + data: None, + }); + } + + Ok(LoginServerOptions { + open_browser: false, + ..LoginServerOptions::new( + config.codex_home.clone(), + CLIENT_ID.to_string(), + config.forced_chatgpt_workspace_id.clone(), + config.cli_auth_credentials_store_mode, + ) + }) + } + + // Deprecated in favor of login_chatgpt_v2. + async fn login_chatgpt_v1(&mut self, request_id: RequestId) { + match self.login_chatgpt_common().await { + Ok(opts) => match run_login_server(opts) { + Ok(server) => { + let login_id = Uuid::new_v4(); + let shutdown_handle = server.cancel_handle(); + + // Replace active login if present. + { + let mut guard = self.active_login.lock().await; + if let Some(existing) = guard.take() { + existing.drop(); + } + *guard = Some(ActiveLogin { + shutdown_handle: shutdown_handle.clone(), + login_id, + }); + } + + // Spawn background task to monitor completion. + let outgoing_clone = self.outgoing.clone(); + let active_login = self.active_login.clone(); + let auth_manager = self.auth_manager.clone(); + let auth_url = server.auth_url.clone(); + tokio::spawn(async move { + let (success, error_msg) = match tokio::time::timeout( + LOGIN_CHATGPT_TIMEOUT, + server.block_until_done(), + ) + .await + { + Ok(Ok(())) => (true, None), + Ok(Err(err)) => (false, Some(format!("Login server error: {err}"))), + Err(_elapsed) => { + shutdown_handle.shutdown(); + (false, Some("Login timed out".to_string())) + } + }; + + let payload = LoginChatGptCompleteNotification { + login_id, + success, + error: error_msg.clone(), + }; + outgoing_clone + .send_server_notification(ServerNotification::LoginChatGptComplete( + payload, + )) + .await; + + if success { + auth_manager.reload(); + + // Notify clients with the actual current auth mode. + let current_auth_method = auth_manager.auth().map(|a| a.mode); + let payload = AuthStatusChangeNotification { + auth_method: current_auth_method, + }; + outgoing_clone + .send_server_notification(ServerNotification::AuthStatusChange( + payload, + )) + .await; + } + + // Clear the active login if it matches this attempt. It may have been replaced or cancelled. + let mut guard = active_login.lock().await; + if guard.as_ref().map(|l| l.login_id) == Some(login_id) { + *guard = None; + } + }); + + let response = LoginChatGptResponse { login_id, auth_url }; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to start login server: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + }, Err(err) => { + self.outgoing.send_error(request_id, err).await; + } + } + } + + async fn login_chatgpt_v2(&mut self, request_id: RequestId) { + match self.login_chatgpt_common().await { + Ok(opts) => match run_login_server(opts) { + Ok(server) => { + let login_id = Uuid::new_v4(); + let shutdown_handle = server.cancel_handle(); + + // Replace active login if present. + { + let mut guard = self.active_login.lock().await; + if let Some(existing) = guard.take() { + existing.drop(); + } + *guard = Some(ActiveLogin { + shutdown_handle: shutdown_handle.clone(), + login_id, + }); + } + + // Spawn background task to monitor completion. + let outgoing_clone = self.outgoing.clone(); + let active_login = self.active_login.clone(); + let auth_manager = self.auth_manager.clone(); + let auth_url = server.auth_url.clone(); + tokio::spawn(async move { + let (success, error_msg) = match tokio::time::timeout( + LOGIN_CHATGPT_TIMEOUT, + server.block_until_done(), + ) + .await + { + Ok(Ok(())) => (true, None), + Ok(Err(err)) => (false, Some(format!("Login server error: {err}"))), + Err(_elapsed) => { + shutdown_handle.shutdown(); + (false, Some("Login timed out".to_string())) + } + }; + + let payload_v2 = AccountLoginCompletedNotification { + login_id: Some(login_id.to_string()), + success, + error: error_msg, + }; + outgoing_clone + .send_server_notification(ServerNotification::AccountLoginCompleted( + payload_v2, + )) + .await; + + if success { + auth_manager.reload(); + + // Notify clients with the actual current auth mode. + let current_auth_method = auth_manager.auth().map(|a| a.mode); + let payload_v2 = AccountUpdatedNotification { + auth_mode: current_auth_method, + }; + outgoing_clone + .send_server_notification(ServerNotification::AccountUpdated( + payload_v2, + )) + .await; + } + + // Clear the active login if it matches this attempt. It may have been replaced or cancelled. + let mut guard = active_login.lock().await; + if guard.as_ref().map(|l| l.login_id) == Some(login_id) { + *guard = None; + } + }); + + let response = codex_app_server_protocol::LoginAccountResponse::Chatgpt { + login_id: login_id.to_string(), + auth_url, + }; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to start login server: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + }, + Err(err) => { + self.outgoing.send_error(request_id, err).await; + } + } + } + + async fn cancel_login_chatgpt_common( + &mut self, + login_id: Uuid, + ) -> std::result::Result<(), JSONRPCErrorError> { + let mut guard = self.active_login.lock().await; + if guard.as_ref().map(|l| l.login_id) == Some(login_id) { + if let Some(active) = guard.take() { + active.drop(); + } + Ok(()) + } else { + Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("login id not found: {login_id}"), + data: None, + }) + } + } + + async fn cancel_login_chatgpt(&mut self, request_id: RequestId, login_id: Uuid) { + match self.cancel_login_chatgpt_common(login_id).await { + Ok(()) => { + self.outgoing + .send_response(request_id, CancelLoginChatGptResponse {}) + .await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn cancel_login_v2(&mut self, request_id: RequestId, params: CancelLoginAccountParams) { + let login_id = params.login_id; + match Uuid::parse_str(&login_id) { + Ok(uuid) => match self.cancel_login_chatgpt_common(uuid).await { + Ok(()) => { + self.outgoing + .send_response(request_id, CancelLoginAccountResponse {}) + .await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + }, + Err(_) => { let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to save api key: {err}"), + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid login id: {login_id}"), data: None, }; self.outgoing.send_error(request_id, error).await; @@ -275,135 +741,9 @@ impl CodexMessageProcessor { } } - async fn login_chatgpt(&mut self, request_id: RequestId) { - let config = self.config.as_ref(); - - let opts = LoginServerOptions { - open_browser: false, - ..LoginServerOptions::new(config.codex_home.clone(), CLIENT_ID.to_string()) - }; - - enum LoginChatGptReply { - Response(LoginChatGptResponse), - Error(JSONRPCErrorError), - } - - let reply = match run_login_server(opts) { - Ok(server) => { - let login_id = Uuid::new_v4(); - let shutdown_handle = server.cancel_handle(); - - // Replace active login if present. - { - let mut guard = self.active_login.lock().await; - if let Some(existing) = guard.take() { - existing.drop(); - } - *guard = Some(ActiveLogin { - shutdown_handle: shutdown_handle.clone(), - login_id, - }); - } - - let response = LoginChatGptResponse { - login_id, - auth_url: server.auth_url.clone(), - }; - - // Spawn background task to monitor completion. - let outgoing_clone = self.outgoing.clone(); - let active_login = self.active_login.clone(); - let auth_manager = self.auth_manager.clone(); - tokio::spawn(async move { - let (success, error_msg) = match tokio::time::timeout( - LOGIN_CHATGPT_TIMEOUT, - server.block_until_done(), - ) - .await - { - Ok(Ok(())) => (true, None), - Ok(Err(err)) => (false, Some(format!("Login server error: {err}"))), - Err(_elapsed) => { - // Timeout: cancel server and report - shutdown_handle.shutdown(); - (false, Some("Login timed out".to_string())) - } - }; - let payload = LoginChatGptCompleteNotification { - login_id, - success, - error: error_msg, - }; - outgoing_clone - .send_server_notification(ServerNotification::LoginChatGptComplete(payload)) - .await; - - // Send an auth status change notification. - if success { - // Update in-memory auth cache now that login completed. - auth_manager.reload(); - - // Notify clients with the actual current auth mode. - let current_auth_method = auth_manager.auth().map(|a| a.mode); - let payload = AuthStatusChangeNotification { - auth_method: current_auth_method, - }; - outgoing_clone - .send_server_notification(ServerNotification::AuthStatusChange(payload)) - .await; - } - - // Clear the active login if it matches this attempt. It may have been replaced or cancelled. - let mut guard = active_login.lock().await; - if guard.as_ref().map(|l| l.login_id) == Some(login_id) { - *guard = None; - } - }); - - LoginChatGptReply::Response(response) - } - Err(err) => LoginChatGptReply::Error(JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to start login server: {err}"), - data: None, - }), - }; - - match reply { - LoginChatGptReply::Response(resp) => { - self.outgoing.send_response(request_id, resp).await - } - LoginChatGptReply::Error(err) => self.outgoing.send_error(request_id, err).await, - } - } - - async fn cancel_login_chatgpt(&mut self, request_id: RequestId, login_id: Uuid) { - let mut guard = self.active_login.lock().await; - if guard.as_ref().map(|l| l.login_id) == Some(login_id) { - if let Some(active) = guard.take() { - active.drop(); - } - drop(guard); - self.outgoing - .send_response( - request_id, - codex_app_server_protocol::CancelLoginChatGptResponse {}, - ) - .await; - } else { - drop(guard); - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("login id not found: {login_id}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - } - - async fn logout_chatgpt(&mut self, request_id: RequestId) { + async fn logout_common(&mut self) -> std::result::Result, JSONRPCErrorError> { + // Cancel any active login attempt. { - // Cancel any active login attempt. let mut guard = self.active_login.lock().await; if let Some(active) = guard.take() { active.drop(); @@ -411,44 +751,68 @@ impl CodexMessageProcessor { } if let Err(err) = self.auth_manager.logout() { - let error = JSONRPCErrorError { + return Err(JSONRPCErrorError { code: INTERNAL_ERROR_CODE, message: format!("logout failed: {err}"), data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + }); } - self.outgoing - .send_response( - request_id, - codex_app_server_protocol::LogoutChatGptResponse {}, - ) - .await; - - // Send auth status change notification reflecting the current auth mode - // after logout. - let current_auth_method = self.auth_manager.auth().map(|auth| auth.mode); - let payload = AuthStatusChangeNotification { - auth_method: current_auth_method, - }; - self.outgoing - .send_server_notification(ServerNotification::AuthStatusChange(payload)) - .await; + // Reflect the current auth method after logout (likely None). + Ok(self.auth_manager.auth().map(|auth| auth.mode)) } - async fn get_auth_status( - &self, - request_id: RequestId, - params: codex_app_server_protocol::GetAuthStatusParams, - ) { + async fn logout_v1(&mut self, request_id: RequestId) { + match self.logout_common().await { + Ok(current_auth_method) => { + self.outgoing + .send_response(request_id, LogoutChatGptResponse {}) + .await; + + let payload = AuthStatusChangeNotification { + auth_method: current_auth_method, + }; + self.outgoing + .send_server_notification(ServerNotification::AuthStatusChange(payload)) + .await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn logout_v2(&mut self, request_id: RequestId) { + match self.logout_common().await { + Ok(current_auth_method) => { + self.outgoing + .send_response(request_id, LogoutAccountResponse {}) + .await; + + let payload_v2 = AccountUpdatedNotification { + auth_mode: current_auth_method, + }; + self.outgoing + .send_server_notification(ServerNotification::AccountUpdated(payload_v2)) + .await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn refresh_token_if_requested(&self, do_refresh: bool) { + if do_refresh && let Err(err) = self.auth_manager.refresh_token().await { + tracing::warn!("failed to refresh token whilte getting account: {err}"); + } + } + + async fn get_auth_status(&self, request_id: RequestId, params: GetAuthStatusParams) { let include_token = params.include_token.unwrap_or(false); let do_refresh = params.refresh_token.unwrap_or(false); - if do_refresh && let Err(err) = self.auth_manager.refresh_token().await { - tracing::warn!("failed to refresh token while getting auth status: {err}"); - } + self.refresh_token_if_requested(do_refresh).await; // Determine whether auth is required based on the active model provider. // If a custom provider is configured with `requires_openai_auth == false`, @@ -456,7 +820,7 @@ impl CodexMessageProcessor { let requires_openai_auth = self.config.model_provider.requires_openai_auth; let response = if !requires_openai_auth { - codex_app_server_protocol::GetAuthStatusResponse { + GetAuthStatusResponse { auth_method: None, auth_token: None, requires_openai_auth: Some(false), @@ -476,13 +840,13 @@ impl CodexMessageProcessor { (None, None) } }; - codex_app_server_protocol::GetAuthStatusResponse { + GetAuthStatusResponse { auth_method: reported_auth_method, auth_token: token_opt, requires_openai_auth: Some(true), } } - None => codex_app_server_protocol::GetAuthStatusResponse { + None => GetAuthStatusResponse { auth_method: None, auth_token: None, requires_openai_auth: Some(true), @@ -493,12 +857,111 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + async fn get_account(&self, request_id: RequestId, params: GetAccountParams) { + let do_refresh = params.refresh_token; + + self.refresh_token_if_requested(do_refresh).await; + + // Whether auth is required for the active model provider. + let requires_openai_auth = self.config.model_provider.requires_openai_auth; + + if !requires_openai_auth { + let response = GetAccountResponse { + account: None, + requires_openai_auth, + }; + self.outgoing.send_response(request_id, response).await; + return; + } + + let account = match self.auth_manager.auth() { + Some(auth) => Some(match auth.mode { + AuthMode::ApiKey => Account::ApiKey {}, + AuthMode::ChatGPT => { + let email = auth.get_account_email(); + let plan_type = auth.account_plan_type(); + + match (email, plan_type) { + (Some(email), Some(plan_type)) => Account::Chatgpt { email, plan_type }, + _ => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: + "email and plan type are required for chatgpt authentication" + .to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + } + } + }), + None => None, + }; + + let response = GetAccountResponse { + account, + requires_openai_auth, + }; + self.outgoing.send_response(request_id, response).await; + } + async fn get_user_agent(&self, request_id: RequestId) { let user_agent = get_codex_user_agent(); let response = GetUserAgentResponse { user_agent }; self.outgoing.send_response(request_id, response).await; } + async fn get_account_rate_limits(&self, request_id: RequestId) { + match self.fetch_account_rate_limits().await { + Ok(rate_limits) => { + let response = GetAccountRateLimitsResponse { + rate_limits: rate_limits.into(), + }; + self.outgoing.send_response(request_id, response).await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn fetch_account_rate_limits(&self) -> Result { + let Some(auth) = self.auth_manager.auth() else { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "codex account authentication required to read rate limits".to_string(), + data: None, + }); + }; + + if auth.mode != AuthMode::ChatGPT { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "chatgpt authentication required to read rate limits".to_string(), + data: None, + }); + } + + let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) + .await + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to construct backend client: {err}"), + data: None, + })?; + + client + .get_rate_limits() + .await + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to fetch codex rate limits: {err}"), + data: None, + }) + } + async fn get_user_saved_config(&self, request_id: RequestId) { let toml_value = match load_config_as_toml(&self.config.codex_home).await { Ok(val) => val, @@ -535,12 +998,8 @@ impl CodexMessageProcessor { } async fn get_user_info(&self, request_id: RequestId) { - // Read alleged user email from auth.json (best-effort; not verified). - let auth_path = get_auth_file(&self.config.codex_home); - let alleged_user_email = match try_read_auth_json(&auth_path) { - Ok(auth) => auth.tokens.and_then(|t| t.id_token.email), - Err(_) => None, - }; + // Read alleged user email from cached auth (best-effort; not verified). + let alleged_user_email = self.auth_manager.auth().and_then(|a| a.get_account_email()); let response = UserInfoResponse { alleged_user_email }; self.outgoing.send_response(request_id, response).await; @@ -551,19 +1010,12 @@ impl CodexMessageProcessor { model, reasoning_effort, } = params; - let effort_str = reasoning_effort.map(|effort| effort.to_string()); - let overrides: [(&[&str], Option<&str>); 2] = [ - (&[CONFIG_KEY_MODEL], model.as_deref()), - (&[CONFIG_KEY_EFFORT], effort_str.as_deref()), - ]; - - match persist_overrides_and_clear_if_none( - &self.config.codex_home, - self.config.active_profile.as_deref(), - &overrides, - ) - .await + match ConfigEditsBuilder::new(&self.config.codex_home) + .with_profile(self.config.active_profile.as_deref()) + .set_model(model.as_deref(), reasoning_effort) + .apply() + .await { Ok(()) => { let response = SetDefaultModelResponse {}; @@ -572,7 +1024,7 @@ impl CodexMessageProcessor { Err(err) => { let error = JSONRPCErrorError { code: INTERNAL_ERROR_CODE, - message: format!("failed to persist overrides: {err}"), + message: format!("failed to persist model selection: {err}"), data: None, }; self.outgoing.send_error(request_id, error).await; @@ -603,6 +1055,7 @@ impl CodexMessageProcessor { env, with_escalated_permissions: None, justification: None, + arg0: None, }; let effective_policy = params @@ -653,19 +1106,47 @@ impl CodexMessageProcessor { } async fn process_new_conversation(&self, request_id: RequestId, params: NewConversationParams) { - let config = - match derive_config_from_params(params, self.codex_linux_sandbox_exe.clone()).await { - Ok(config) => config, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("error deriving config: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }; + let NewConversationParams { + model, + model_provider, + profile, + cwd, + approval_policy, + sandbox: sandbox_mode, + config: cli_overrides, + base_instructions, + developer_instructions, + compact_prompt, + include_apply_patch_tool, + } = params; + + let overrides = ConfigOverrides { + model, + config_profile: profile, + cwd: cwd.map(PathBuf::from), + approval_policy, + sandbox_mode, + model_provider, + codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), + base_instructions, + developer_instructions, + compact_prompt, + include_apply_patch_tool, + ..Default::default() + }; + + let config = match derive_config_from_params(overrides, cli_overrides).await { + Ok(config) => config, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("error deriving config: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; match self.conversation_manager.new_conversation(config).await { Ok(conversation_id) => { @@ -693,72 +1174,23 @@ impl CodexMessageProcessor { } } - async fn handle_list_conversations( - &self, - request_id: RequestId, - params: ListConversationsParams, - ) { - let page_size = params.page_size.unwrap_or(25); - // Decode the optional cursor string to a Cursor via serde (Cursor implements Deserialize from string) - let cursor_obj: Option = match params.cursor { - Some(s) => serde_json::from_str::(&format!("\"{s}\"")).ok(), - None => None, - }; - let cursor_ref = cursor_obj.as_ref(); - - let page = match RolloutRecorder::list_conversations( - &self.config.codex_home, - page_size, - cursor_ref, - INTERACTIVE_SESSION_SOURCES, - ) - .await - { - Ok(p) => p, - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to list conversations: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } + async fn thread_start(&mut self, request_id: RequestId, params: ThreadStartParams) { + // Build ConfigOverrides directly from ThreadStartParams for config derivation. + let cli_overrides = params.config; + let overrides = ConfigOverrides { + model: params.model, + cwd: params.cwd.map(PathBuf::from), + approval_policy: params.approval_policy.map(AskForApproval::to_core), + sandbox_mode: params.sandbox.map(SandboxMode::to_core), + model_provider: params.model_provider, + codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), + base_instructions: params.base_instructions, + developer_instructions: params.developer_instructions, + ..Default::default() }; - let items = page - .items - .into_iter() - .filter_map(|it| extract_conversation_summary(it.path, &it.head)) - .collect(); - - // Encode next_cursor as a plain string - let next_cursor = match page.next_cursor { - Some(c) => match serde_json::to_value(&c) { - Ok(serde_json::Value::String(s)) => Some(s), - _ => None, - }, - None => None, - }; - - let response = ListConversationsResponse { items, next_cursor }; - self.outgoing.send_response(request_id, response).await; - } - - async fn handle_resume_conversation( - &self, - request_id: RequestId, - params: ResumeConversationParams, - ) { - // Derive a Config using the same logic as new conversation, honoring overrides if provided. - let config = match params.overrides { - Some(overrides) => { - derive_config_from_params(overrides, self.codex_linux_sandbox_exe.clone()).await - } - None => Ok(self.config.as_ref().clone()), - }; - let config = match config { - Ok(cfg) => cfg, + let config = match derive_config_from_params(overrides, cli_overrides).await { + Ok(config) => config, Err(err) => { let error = JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, @@ -770,11 +1202,601 @@ impl CodexMessageProcessor { } }; + match self.conversation_manager.new_conversation(config).await { + Ok(new_conv) => { + let conversation_id = new_conv.conversation_id; + let rollout_path = new_conv.session_configured.rollout_path.clone(); + let fallback_provider = self.config.model_provider_id.as_str(); + + // A bit hacky, but the summary contains a lot of useful information for the thread + // that unfortunately does not get returned from conversation_manager.new_conversation(). + let thread = match read_summary_from_rollout( + rollout_path.as_path(), + fallback_provider, + ) + .await + { + Ok(summary) => summary_to_thread(summary), + Err(err) => { + warn!( + "failed to load summary for new thread {}: {}", + conversation_id, err + ); + Thread { + id: conversation_id.to_string(), + preview: String::new(), + model_provider: self.config.model_provider_id.clone(), + created_at: chrono::Utc::now().timestamp(), + } + } + }; + + let response = ThreadStartResponse { + thread: thread.clone(), + }; + + // Auto-attach a conversation listener when starting a thread. + // Use the same behavior as the v1 API with experimental_raw_events=false. + if let Err(err) = self + .attach_conversation_listener(conversation_id, false) + .await + { + tracing::warn!( + "failed to attach listener for conversation {}: {}", + conversation_id, + err.message + ); + } + + self.outgoing.send_response(request_id, response).await; + + let notif = ThreadStartedNotification { thread }; + self.outgoing + .send_server_notification(ServerNotification::ThreadStarted(notif)) + .await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("error creating thread: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn thread_archive(&mut self, request_id: RequestId, params: ThreadArchiveParams) { + let conversation_id = match ConversationId::from_string(¶ms.thread_id) { + Ok(id) => id, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid thread id: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let rollout_path = match find_conversation_path_by_id_str( + &self.config.codex_home, + &conversation_id.to_string(), + ) + .await + { + Ok(Some(p)) => p, + Ok(None) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("no rollout found for conversation id {conversation_id}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("failed to locate conversation id {conversation_id}: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + match self + .archive_conversation_common(conversation_id, &rollout_path) + .await + { + Ok(()) => { + let response = ThreadArchiveResponse {}; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + self.outgoing.send_error(request_id, err).await; + } + } + } + + async fn thread_list(&self, request_id: RequestId, params: ThreadListParams) { + let ThreadListParams { + cursor, + limit, + model_providers, + } = params; + + let page_size = limit.unwrap_or(25).max(1) as usize; + + let (summaries, next_cursor) = match self + .list_conversations_common(page_size, cursor, model_providers) + .await + { + Ok(r) => r, + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let data = summaries.into_iter().map(summary_to_thread).collect(); + + let response = ThreadListResponse { data, next_cursor }; + self.outgoing.send_response(request_id, response).await; + } + + async fn thread_resume(&mut self, request_id: RequestId, params: ThreadResumeParams) { + let conversation_id = match ConversationId::from_string(¶ms.thread_id) { + Ok(id) => id, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid thread id: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let path = match find_conversation_path_by_id_str( + &self.config.codex_home, + &conversation_id.to_string(), + ) + .await + { + Ok(Some(p)) => p, + Ok(None) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("no rollout found for conversation id {conversation_id}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("failed to locate conversation id {conversation_id}: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let fallback_provider = self.config.model_provider_id.as_str(); + let summary = match read_summary_from_rollout(&path, fallback_provider).await { + Ok(s) => s, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("failed to load rollout `{}`: {err}", path.display()), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let initial_history = match RolloutRecorder::get_rollout_history(&summary.path).await { + Ok(initial_history) => initial_history, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!( + "failed to load rollout `{}` for conversation {conversation_id}: {err}", + summary.path.display() + ), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + match self .conversation_manager - .resume_conversation_from_rollout( + .resume_conversation_with_history( + self.config.as_ref().clone(), + initial_history, + self.auth_manager.clone(), + ) + .await + { + Ok(_) => { + let thread = summary_to_thread(summary); + + // Auto-attach a conversation listener when resuming a thread. + if let Err(err) = self + .attach_conversation_listener(conversation_id, false) + .await + { + tracing::warn!( + "failed to attach listener for conversation {}: {}", + conversation_id, + err.message + ); + } + + let response = ThreadResumeResponse { thread }; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("error resuming thread: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn get_conversation_summary( + &self, + request_id: RequestId, + params: GetConversationSummaryParams, + ) { + let path = match params { + GetConversationSummaryParams::RolloutPath { rollout_path } => { + if rollout_path.is_relative() { + self.config.codex_home.join(&rollout_path) + } else { + rollout_path + } + } + GetConversationSummaryParams::ConversationId { conversation_id } => { + match codex_core::find_conversation_path_by_id_str( + &self.config.codex_home, + &conversation_id.to_string(), + ) + .await + { + Ok(Some(p)) => p, + _ => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!( + "no rollout found for conversation id {conversation_id}" + ), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + } + } + }; + + let fallback_provider = self.config.model_provider_id.as_str(); + + match read_summary_from_rollout(&path, fallback_provider).await { + Ok(summary) => { + let response = GetConversationSummaryResponse { summary }; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!( + "failed to load conversation summary from {}: {}", + path.display(), + err + ), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn handle_list_conversations( + &self, + request_id: RequestId, + params: ListConversationsParams, + ) { + let ListConversationsParams { + page_size, + cursor, + model_providers, + } = params; + let page_size = page_size.unwrap_or(25).max(1); + + match self + .list_conversations_common(page_size, cursor, model_providers) + .await + { + Ok((items, next_cursor)) => { + let response = ListConversationsResponse { items, next_cursor }; + self.outgoing.send_response(request_id, response).await; + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + } + }; + } + + async fn list_conversations_common( + &self, + page_size: usize, + cursor: Option, + model_providers: Option>, + ) -> Result<(Vec, Option), JSONRPCErrorError> { + let cursor_obj: Option = cursor.as_ref().and_then(|s| parse_cursor(s)); + let cursor_ref = cursor_obj.as_ref(); + + let model_provider_filter = match model_providers { + Some(providers) => { + if providers.is_empty() { + None + } else { + Some(providers) + } + } + None => Some(vec![self.config.model_provider_id.clone()]), + }; + let fallback_provider = self.config.model_provider_id.clone(); + + let page = match RolloutRecorder::list_conversations( + &self.config.codex_home, + page_size, + cursor_ref, + INTERACTIVE_SESSION_SOURCES, + model_provider_filter.as_deref(), + fallback_provider.as_str(), + ) + .await + { + Ok(p) => p, + Err(err) => { + return Err(JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to list conversations: {err}"), + data: None, + }); + } + }; + + let items = page + .items + .into_iter() + .filter_map(|it| { + let session_meta_line = it.head.first().and_then(|first| { + serde_json::from_value::(first.clone()).ok() + })?; + extract_conversation_summary( + it.path, + &it.head, + &session_meta_line.meta, + session_meta_line.git.as_ref(), + fallback_provider.as_str(), + ) + }) + .collect::>(); + + // Encode next_cursor as a plain string + let next_cursor = page + .next_cursor + .and_then(|cursor| serde_json::to_value(&cursor).ok()) + .and_then(|value| value.as_str().map(str::to_owned)); + + Ok((items, next_cursor)) + } + + async fn list_models(&self, request_id: RequestId, params: ModelListParams) { + let ModelListParams { limit, cursor } = params; + let auth_mode = self.auth_manager.auth().map(|auth| auth.mode); + let models = supported_models(auth_mode); + let total = models.len(); + + if total == 0 { + let response = ModelListResponse { + data: Vec::new(), + next_cursor: None, + }; + self.outgoing.send_response(request_id, response).await; + return; + } + + let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; + let effective_limit = effective_limit.min(total); + let start = match cursor { + Some(cursor) => match cursor.parse::() { + Ok(idx) => idx, + Err(_) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid cursor: {cursor}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }, + None => 0, + }; + + if start > total { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("cursor {start} exceeds total models {total}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + + let end = start.saturating_add(effective_limit).min(total); + let items = models[start..end].to_vec(); + let next_cursor = if end < total { + Some(end.to_string()) + } else { + None + }; + let response = ModelListResponse { + data: items, + next_cursor, + }; + self.outgoing.send_response(request_id, response).await; + } + + async fn handle_resume_conversation( + &self, + request_id: RequestId, + params: ResumeConversationParams, + ) { + let ResumeConversationParams { + path, + conversation_id, + history, + overrides, + } = params; + + // Derive a Config using the same logic as new conversation, honoring overrides if provided. + let config = match overrides { + Some(overrides) => { + let NewConversationParams { + model, + model_provider, + profile, + cwd, + approval_policy, + sandbox: sandbox_mode, + config: cli_overrides, + base_instructions, + developer_instructions, + compact_prompt, + include_apply_patch_tool, + } = overrides; + + let overrides = ConfigOverrides { + model, + config_profile: profile, + cwd: cwd.map(PathBuf::from), + approval_policy, + sandbox_mode, + model_provider, + codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), + base_instructions, + developer_instructions, + compact_prompt, + include_apply_patch_tool, + ..Default::default() + }; + + derive_config_from_params(overrides, cli_overrides).await + } + None => Ok(self.config.as_ref().clone()), + }; + let config = match config { + Ok(cfg) => cfg, + Err(err) => { + self.send_invalid_request_error( + request_id, + format!("error deriving config: {err}"), + ) + .await; + return; + } + }; + + let conversation_history = if let Some(path) = path { + match RolloutRecorder::get_rollout_history(&path).await { + Ok(initial_history) => initial_history, + Err(err) => { + self.send_invalid_request_error( + request_id, + format!("failed to load rollout `{}`: {err}", path.display()), + ) + .await; + return; + } + } + } else if let Some(conversation_id) = conversation_id { + match find_conversation_path_by_id_str( + &self.config.codex_home, + &conversation_id.to_string(), + ) + .await + { + Ok(Some(found_path)) => { + match RolloutRecorder::get_rollout_history(&found_path).await { + Ok(initial_history) => initial_history, + Err(err) => { + self.send_invalid_request_error( + request_id, + format!( + "failed to load rollout `{}` for conversation {conversation_id}: {err}", + found_path.display() + ), + ).await; + return; + } + } + } + Ok(None) => { + self.send_invalid_request_error( + request_id, + format!("no rollout found for conversation id {conversation_id}"), + ) + .await; + return; + } + Err(err) => { + self.send_invalid_request_error( + request_id, + format!("failed to locate conversation id {conversation_id}: {err}"), + ) + .await; + return; + } + } + } else { + match history { + Some(history) if !history.is_empty() => InitialHistory::Forked( + history.into_iter().map(RolloutItem::ResponseItem).collect(), + ), + Some(_) | None => { + self.send_invalid_request_error( + request_id, + "either path, conversation id or non empty history must be provided" + .to_string(), + ) + .await; + return; + } + } + }; + + match self + .conversation_manager + .resume_conversation_with_history( config, - params.path.clone(), + conversation_history, self.auth_manager.clone(), ) .await @@ -797,24 +1819,16 @@ impl CodexMessageProcessor { }, )) .await; - let initial_messages = session_configured.initial_messages.map(|msgs| { - msgs.into_iter() - .filter(|event| { - // Don't send non-plain user messages (like user instructions - // or environment context) back so they don't get rendered. - if let EventMsg::UserMessage(user_message) = event { - return matches!(user_message.kind, Some(InputMessageKind::Plain)); - } - true - }) - .collect() - }); + let initial_messages = session_configured + .initial_messages + .map(|msgs| msgs.into_iter().collect()); // Reply with conversation id + model and initial messages (when present) - let response = codex_app_server_protocol::ResumeConversationResponse { + let response = ResumeConversationResponse { conversation_id, model: session_configured.model.clone(), initial_messages, + rollout_path: session_configured.rollout_path.clone(), }; self.outgoing.send_response(request_id, response).await; } @@ -829,68 +1843,112 @@ impl CodexMessageProcessor { } } - async fn archive_conversation(&self, request_id: RequestId, params: ArchiveConversationParams) { + async fn send_invalid_request_error(&self, request_id: RequestId, message: String) { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message, + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + + async fn archive_conversation( + &mut self, + request_id: RequestId, + params: ArchiveConversationParams, + ) { let ArchiveConversationParams { conversation_id, rollout_path, } = params; - // Verify that the rollout path is in the sessions directory or else - // a malicious client could specify an arbitrary path. + match self + .archive_conversation_common(conversation_id, &rollout_path) + .await + { + Ok(()) => { + tracing::info!("thread/archive succeeded for {conversation_id}"); + let response = ArchiveConversationResponse {}; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + tracing::warn!( + "thread/archive failed for {conversation_id}: {}", + err.message + ); + self.outgoing.send_error(request_id, err).await; + } + } + } + + async fn archive_conversation_common( + &mut self, + conversation_id: ConversationId, + rollout_path: &Path, + ) -> Result<(), JSONRPCErrorError> { + // Verify rollout_path is under sessions dir. let rollout_folder = self.config.codex_home.join(codex_core::SESSIONS_SUBDIR); - let canonical_rollout_path = tokio::fs::canonicalize(&rollout_path).await; + + let canonical_sessions_dir = match tokio::fs::canonicalize(&rollout_folder).await { + Ok(path) => path, + Err(err) => { + return Err(JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!( + "failed to archive conversation: unable to resolve sessions directory: {err}" + ), + data: None, + }); + } + }; + let canonical_rollout_path = tokio::fs::canonicalize(rollout_path).await; let canonical_rollout_path = if let Ok(path) = canonical_rollout_path - && path.starts_with(&rollout_folder) + && path.starts_with(&canonical_sessions_dir) { path } else { - let error = JSONRPCErrorError { + return Err(JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, message: format!( "rollout path `{}` must be in sessions directory", rollout_path.display() ), data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + }); }; + // Verify file name matches conversation id. let required_suffix = format!("{conversation_id}.jsonl"); let Some(file_name) = canonical_rollout_path.file_name().map(OsStr::to_owned) else { - let error = JSONRPCErrorError { + return Err(JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, message: format!( "rollout path `{}` missing file name", rollout_path.display() ), data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + }); }; - if !file_name .to_string_lossy() .ends_with(required_suffix.as_str()) { - let error = JSONRPCErrorError { + return Err(JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, message: format!( "rollout path `{}` does not match conversation id {conversation_id}", rollout_path.display() ), data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + }); } - let removed_conversation = self + // If the conversation is active, request shutdown and wait briefly. + if let Some(conversation) = self .conversation_manager .remove_conversation(&conversation_id) - .await; - if let Some(conversation) = removed_conversation { + .await + { info!("conversation {conversation_id} was active; shutting down"); let conversation_clone = conversation.clone(); let notify = Arc::new(tokio::sync::Notify::new()); @@ -899,20 +1957,24 @@ impl CodexMessageProcessor { // Establish the listener for ShutdownComplete before submitting // Shutdown so it is not missed. let is_shutdown = tokio::spawn(async move { + // Create the notified future outside the loop to avoid losing notifications. + let notified = notify_clone.notified(); + tokio::pin!(notified); loop { select! { - _ = notify_clone.notified() => { - break; - } + _ = &mut notified => { break; } event = conversation_clone.next_event() => { - if let Ok(event) = event && matches!(event.msg, EventMsg::ShutdownComplete) { - break; + match event { + Ok(event) => { + if matches!(event.msg, EventMsg::ShutdownComplete) { break; } + } + // Break on errors to avoid tight loops when the agent loop has exited. + Err(_) => { break; } } } } } }); - // Request shutdown. match conversation.submit(Op::Shutdown).await { Ok(_) => { @@ -923,20 +1985,21 @@ impl CodexMessageProcessor { } _ = tokio::time::sleep(Duration::from_secs(10)) => { warn!("conversation {conversation_id} shutdown timed out; proceeding with archive"); - notify.notify_one(); + // Wake any waiter; use notify_waiters to avoid missing the signal. + notify.notify_waiters(); + // Perhaps we lost a shutdown race, so let's continue to + // clean up the .jsonl file. } } } Err(err) => { error!("failed to submit Shutdown to conversation {conversation_id}: {err}"); - notify.notify_one(); - // Perhaps we lost a shutdown race, so let's continue to - // clean up the .jsonl file. + notify.notify_waiters(); } } } - // Move the .jsonl file to the archived sessions subdir. + // Move the rollout file to archived. let result: std::io::Result<()> = async { let archive_folder = self .config @@ -948,20 +2011,11 @@ impl CodexMessageProcessor { } .await; - match result { - Ok(()) => { - let response = ArchiveConversationResponse {}; - self.outgoing.send_response(request_id, response).await; - } - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("failed to archive conversation: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - } - } + result.map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to archive conversation: {err}"), + data: None, + }) } async fn send_user_message(&self, request_id: RequestId, params: SendUserMessageParams) { @@ -1081,19 +2135,30 @@ impl CodexMessageProcessor { // Record the pending interrupt so we can reply when TurnAborted arrives. { let mut map = self.pending_interrupts.lock().await; - map.entry(conversation_id).or_default().push(request_id); + map.entry(conversation_id) + .or_default() + .push((request_id, ApiVersion::V1)); } // Submit the interrupt; we'll respond upon TurnAborted. let _ = conversation.submit(Op::Interrupt).await; } - async fn add_conversation_listener( - &mut self, - request_id: RequestId, - params: AddConversationListenerParams, - ) { - let AddConversationListenerParams { conversation_id } = params; + async fn turn_start(&self, request_id: RequestId, params: TurnStartParams) { + // Resolve conversation id from v2 thread id string. + let conversation_id = match ConversationId::from_string(¶ms.thread_id) { + Ok(id) => id, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid thread id: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let Ok(conversation) = self .conversation_manager .get_conversation(conversation_id) @@ -1108,59 +2173,140 @@ impl CodexMessageProcessor { return; }; - let subscription_id = Uuid::new_v4(); - let (cancel_tx, mut cancel_rx) = oneshot::channel(); - self.conversation_listeners - .insert(subscription_id, cancel_tx); - let outgoing_for_task = self.outgoing.clone(); - let pending_interrupts = self.pending_interrupts.clone(); - tokio::spawn(async move { - loop { - tokio::select! { - _ = &mut cancel_rx => { - // User has unsubscribed, so exit this task. - break; - } - event = conversation.next_event() => { - let event = match event { - Ok(event) => event, - Err(err) => { - tracing::warn!("conversation.next_event() failed with: {err}"); - break; - } - }; + // Keep a copy of v2 inputs for the notification payload. + let v2_inputs_for_notif = params.input.clone(); - // For now, we send a notification for every event, - // JSON-serializing the `Event` as-is, but these should - // be migrated to be variants of `ServerNotification` - // instead. - let method = format!("codex/event/{}", event.msg); - let mut params = match serde_json::to_value(event.clone()) { - Ok(serde_json::Value::Object(map)) => map, - Ok(_) => { - error!("event did not serialize to an object"); - continue; - } - Err(err) => { - error!("failed to serialize event: {err}"); - continue; - } - }; - params.insert("conversationId".to_string(), conversation_id.to_string().into()); + // Map v2 input items to core input items. + let mapped_items: Vec = params + .input + .into_iter() + .map(V2UserInput::into_core) + .collect(); - outgoing_for_task.send_notification(OutgoingNotification { - method, - params: Some(params.into()), - }) - .await; + let has_any_overrides = params.cwd.is_some() + || params.approval_policy.is_some() + || params.sandbox_policy.is_some() + || params.model.is_some() + || params.effort.is_some() + || params.summary.is_some(); - apply_bespoke_event_handling(event.clone(), conversation_id, conversation.clone(), outgoing_for_task.clone(), pending_interrupts.clone()).await; - } - } + // If any overrides are provided, update the session turn context first. + if has_any_overrides { + let _ = conversation + .submit(Op::OverrideTurnContext { + cwd: params.cwd, + approval_policy: params.approval_policy.map(AskForApproval::to_core), + sandbox_policy: params.sandbox_policy.map(|p| p.to_core()), + model: params.model, + effort: params.effort.map(Some), + summary: params.summary, + }) + .await; + } + + // Start the turn by submitting the user input. Return its submission id as turn_id. + let turn_id = conversation + .submit(Op::UserInput { + items: mapped_items, + }) + .await; + + match turn_id { + Ok(turn_id) => { + let turn = Turn { + id: turn_id.clone(), + items: vec![ThreadItem::UserMessage { + id: turn_id, + content: v2_inputs_for_notif, + }], + status: TurnStatus::InProgress, + error: None, + }; + + let response = TurnStartResponse { turn: turn.clone() }; + self.outgoing.send_response(request_id, response).await; + + // Emit v2 turn/started notification. + let notif = TurnStartedNotification { turn }; + self.outgoing + .send_server_notification(ServerNotification::TurnStarted(notif)) + .await; } - }); - let response = AddConversationSubscriptionResponse { subscription_id }; - self.outgoing.send_response(request_id, response).await; + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to start turn: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn turn_interrupt(&mut self, request_id: RequestId, params: TurnInterruptParams) { + let TurnInterruptParams { thread_id, .. } = params; + + // Resolve conversation id from v2 thread id string. + let conversation_id = match ConversationId::from_string(&thread_id) { + Ok(id) => id, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("invalid thread id: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + let Ok(conversation) = self + .conversation_manager + .get_conversation(conversation_id) + .await + else { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("conversation not found: {conversation_id}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + }; + + // Record the pending interrupt so we can reply when TurnAborted arrives. + { + let mut map = self.pending_interrupts.lock().await; + map.entry(conversation_id) + .or_default() + .push((request_id, ApiVersion::V2)); + } + + // Submit the interrupt; we'll respond upon TurnAborted. + let _ = conversation.submit(Op::Interrupt).await; + } + + async fn add_conversation_listener( + &mut self, + request_id: RequestId, + params: AddConversationListenerParams, + ) { + let AddConversationListenerParams { + conversation_id, + experimental_raw_events, + } = params; + match self + .attach_conversation_listener(conversation_id, experimental_raw_events) + .await + { + Ok(subscription_id) => { + let response = AddConversationSubscriptionResponse { subscription_id }; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + self.outgoing.send_error(request_id, err).await; + } + } } async fn remove_conversation_listener( @@ -1187,6 +2333,97 @@ impl CodexMessageProcessor { } } + async fn attach_conversation_listener( + &mut self, + conversation_id: ConversationId, + experimental_raw_events: bool, + ) -> Result { + let conversation = match self + .conversation_manager + .get_conversation(conversation_id) + .await + { + Ok(conv) => conv, + Err(_) => { + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("conversation not found: {conversation_id}"), + data: None, + }); + } + }; + + let subscription_id = Uuid::new_v4(); + let (cancel_tx, mut cancel_rx) = oneshot::channel(); + self.conversation_listeners + .insert(subscription_id, cancel_tx); + + let outgoing_for_task = self.outgoing.clone(); + let pending_interrupts = self.pending_interrupts.clone(); + tokio::spawn(async move { + loop { + tokio::select! { + _ = &mut cancel_rx => { + // User has unsubscribed, so exit this task. + break; + } + event = conversation.next_event() => { + let event = match event { + Ok(event) => event, + Err(err) => { + tracing::warn!("conversation.next_event() failed with: {err}"); + break; + } + }; + + if let EventMsg::RawResponseItem(_) = &event.msg + && !experimental_raw_events { + continue; + } + + // For now, we send a notification for every event, + // JSON-serializing the `Event` as-is, but these should + // be migrated to be variants of `ServerNotification` + // instead. + let method = format!("codex/event/{}", event.msg); + let mut params = match serde_json::to_value(event.clone()) { + Ok(serde_json::Value::Object(map)) => map, + Ok(_) => { + error!("event did not serialize to an object"); + continue; + } + Err(err) => { + error!("failed to serialize event: {err}"); + continue; + } + }; + params.insert( + "conversationId".to_string(), + conversation_id.to_string().into(), + ); + + outgoing_for_task + .send_notification(OutgoingNotification { + method, + params: Some(params.into()), + }) + .await; + + apply_bespoke_event_handling( + event.clone(), + conversation_id, + conversation.clone(), + outgoing_for_task.clone(), + pending_interrupts.clone(), + ) + .await; + } + } + } + }); + Ok(subscription_id) + } + async fn git_diff_to_origin(&self, request_id: RequestId, cwd: PathBuf) { let diff = git_diff_to_remote(&cwd).await; match diff { @@ -1247,6 +2484,77 @@ impl CodexMessageProcessor { let response = FuzzyFileSearchResponse { files: results }; self.outgoing.send_response(request_id, response).await; } + + async fn upload_feedback(&self, request_id: RequestId, params: FeedbackUploadParams) { + let FeedbackUploadParams { + classification, + reason, + conversation_id, + include_logs, + } = params; + + let snapshot = self.feedback.snapshot(conversation_id); + let thread_id = snapshot.thread_id.clone(); + + let validated_rollout_path = if include_logs { + match conversation_id { + Some(conv_id) => self.resolve_rollout_path(conv_id).await, + None => None, + } + } else { + None + }; + + let upload_result = tokio::task::spawn_blocking(move || { + let rollout_path_ref = validated_rollout_path.as_deref(); + snapshot.upload_feedback( + &classification, + reason.as_deref(), + include_logs, + rollout_path_ref, + ) + }) + .await; + + let upload_result = match upload_result { + Ok(result) => result, + Err(join_err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to upload feedback: {join_err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + + match upload_result { + Ok(()) => { + let response = FeedbackUploadResponse { thread_id }; + self.outgoing.send_response(request_id, response).await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to upload feedback: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + } + } + } + + async fn resolve_rollout_path(&self, conversation_id: ConversationId) -> Option { + match self + .conversation_manager + .get_conversation(conversation_id) + .await + { + Ok(conv) => Some(conv.rollout_path()), + Err(_) => None, + } + } } async fn apply_bespoke_event_handling( @@ -1254,7 +2562,7 @@ async fn apply_bespoke_event_handling( conversation_id: ConversationId, conversation: Arc, outgoing: Arc, - pending_interrupts: Arc>>>, + pending_interrupts: PendingInterrupts, ) { let Event { id: event_id, msg } = event; match msg { @@ -1284,6 +2592,7 @@ async fn apply_bespoke_event_handling( command, cwd, reason, + risk, parsed_cmd, }) => { let params = ExecCommandApprovalParams { @@ -1292,6 +2601,7 @@ async fn apply_bespoke_event_handling( command, cwd, reason, + risk, parsed_cmd, }; let rx = outgoing @@ -1303,6 +2613,17 @@ async fn apply_bespoke_event_handling( on_exec_approval_response(event_id, rx, conversation).await; }); } + EventMsg::TokenCount(token_count_event) => { + if let Some(rate_limits) = token_count_event.rate_limits { + outgoing + .send_server_notification(ServerNotification::AccountRateLimitsUpdated( + AccountRateLimitsUpdatedNotification { + rate_limits: rate_limits.into(), + }, + )) + .await; + } + } // If this is a TurnAborted, reply to any pending interrupt requests. EventMsg::TurnAborted(turn_aborted_event) => { let pending = { @@ -1310,11 +2631,19 @@ async fn apply_bespoke_event_handling( map.remove(&conversation_id).unwrap_or_default() }; if !pending.is_empty() { - let response = InterruptConversationResponse { - abort_reason: turn_aborted_event.reason, - }; - for rid in pending { - outgoing.send_response(rid, response.clone()).await; + for (rid, ver) in pending { + match ver { + ApiVersion::V1 => { + let response = InterruptConversationResponse { + abort_reason: turn_aborted_event.reason.clone(), + }; + outgoing.send_response(rid, response).await; + } + ApiVersion::V2 => { + let response = TurnInterruptResponse {}; + outgoing.send_response(rid, response).await; + } + } } } } @@ -1324,37 +2653,9 @@ async fn apply_bespoke_event_handling( } async fn derive_config_from_params( - params: NewConversationParams, - codex_linux_sandbox_exe: Option, + overrides: ConfigOverrides, + cli_overrides: Option>, ) -> std::io::Result { - let NewConversationParams { - model, - profile, - cwd, - approval_policy, - sandbox: sandbox_mode, - config: cli_overrides, - base_instructions, - include_plan_tool, - include_apply_patch_tool, - } = params; - let overrides = ConfigOverrides { - model, - review_model: None, - config_profile: profile, - cwd: cwd.map(PathBuf::from), - approval_policy, - sandbox_mode, - model_provider: None, - codex_linux_sandbox_exe, - base_instructions, - include_plan_tool, - include_apply_patch_tool, - include_view_image_tool: None, - show_raw_agent_reasoning: None, - tools_web_search_request: None, - }; - let cli_overrides = cli_overrides .unwrap_or_default() .into_iter() @@ -1442,30 +2743,77 @@ async fn on_exec_approval_response( } } +async fn read_summary_from_rollout( + path: &Path, + fallback_provider: &str, +) -> std::io::Result { + let head = read_head_for_summary(path).await?; + + let Some(first) = head.first() else { + return Err(IoError::other(format!( + "rollout at {} is empty", + path.display() + ))); + }; + + let session_meta_line = + serde_json::from_value::(first.clone()).map_err(|_| { + IoError::other(format!( + "rollout at {} does not start with session metadata", + path.display() + )) + })?; + let SessionMetaLine { + meta: session_meta, + git, + } = session_meta_line; + + if let Some(summary) = extract_conversation_summary( + path.to_path_buf(), + &head, + &session_meta, + git.as_ref(), + fallback_provider, + ) { + return Ok(summary); + } + + let timestamp = if session_meta.timestamp.is_empty() { + None + } else { + Some(session_meta.timestamp.clone()) + }; + let model_provider = session_meta + .model_provider + .clone() + .unwrap_or_else(|| fallback_provider.to_string()); + let git_info = git.as_ref().map(map_git_info); + + Ok(ConversationSummary { + conversation_id: session_meta.id, + timestamp, + path: path.to_path_buf(), + preview: String::new(), + model_provider, + cwd: session_meta.cwd, + cli_version: session_meta.cli_version, + source: session_meta.source, + git_info, + }) +} + fn extract_conversation_summary( path: PathBuf, head: &[serde_json::Value], + session_meta: &SessionMeta, + git: Option<&GitInfo>, + fallback_provider: &str, ) -> Option { - let session_meta = match head.first() { - Some(first_line) => serde_json::from_value::(first_line.clone()).ok()?, - None => return None, - }; - let preview = head .iter() .filter_map(|value| serde_json::from_value::(value.clone()).ok()) - .find_map(|item| match item { - ResponseItem::Message { content, .. } => { - content.into_iter().find_map(|content| match content { - ContentItem::InputText { text } => { - match InputMessageKind::from(("user", &text)) { - InputMessageKind::Plain => Some(text), - _ => None, - } - } - _ => None, - }) - } + .find_map(|item| match codex_core::parse_turn_item(&item) { + Some(TurnItem::UserMessage(user)) => Some(user.message()), _ => None, })?; @@ -1479,21 +2827,69 @@ fn extract_conversation_summary( } else { Some(session_meta.timestamp.clone()) }; + let conversation_id = session_meta.id; + let model_provider = session_meta + .model_provider + .clone() + .unwrap_or_else(|| fallback_provider.to_string()); + let git_info = git.map(map_git_info); Some(ConversationSummary { - conversation_id: session_meta.id, + conversation_id, timestamp, path, preview: preview.to_string(), + model_provider, + cwd: session_meta.cwd.clone(), + cli_version: session_meta.cli_version.clone(), + source: session_meta.source.clone(), + git_info, }) } +fn map_git_info(git_info: &GitInfo) -> ConversationGitInfo { + ConversationGitInfo { + sha: git_info.commit_hash.clone(), + branch: git_info.branch.clone(), + origin_url: git_info.repository_url.clone(), + } +} + +fn parse_datetime(timestamp: Option<&str>) -> Option> { + timestamp.and_then(|ts| { + chrono::DateTime::parse_from_rfc3339(ts) + .ok() + .map(|dt| dt.with_timezone(&chrono::Utc)) + }) +} + +fn summary_to_thread(summary: ConversationSummary) -> Thread { + let ConversationSummary { + conversation_id, + preview, + timestamp, + model_provider, + .. + } = summary; + + let created_at = parse_datetime(timestamp.as_deref()); + + Thread { + id: conversation_id.to_string(), + preview, + model_provider, + created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0), + } +} + #[cfg(test)] mod tests { use super::*; use anyhow::Result; + use codex_protocol::protocol::SessionSource; use pretty_assertions::assert_eq; use serde_json::json; + use tempfile::TempDir; #[test] fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> { @@ -1508,7 +2904,8 @@ mod tests { "cwd": "/", "originator": "codex", "cli_version": "0.0.0", - "instructions": null + "instructions": null, + "model_provider": "test-provider" }), json!({ "type": "message", @@ -1528,15 +2925,73 @@ mod tests { }), ]; - let summary = extract_conversation_summary(path.clone(), &head).expect("summary"); + let session_meta = serde_json::from_value::(head[0].clone())?; - assert_eq!(summary.conversation_id, conversation_id); - assert_eq!( - summary.timestamp, - Some("2025-09-05T16:53:11.850Z".to_string()) - ); - assert_eq!(summary.path, path); - assert_eq!(summary.preview, "Count to 5"); + let summary = + extract_conversation_summary(path.clone(), &head, &session_meta, None, "test-provider") + .expect("summary"); + + let expected = ConversationSummary { + conversation_id, + timestamp, + path, + preview: "Count to 5".to_string(), + model_provider: "test-provider".to_string(), + cwd: PathBuf::from("/"), + cli_version: "0.0.0".to_string(), + source: SessionSource::VSCode, + git_info: None, + }; + + assert_eq!(summary, expected); + Ok(()) + } + + #[tokio::test] + async fn read_summary_from_rollout_returns_empty_preview_when_no_user_message() -> Result<()> { + use codex_protocol::protocol::RolloutItem; + use codex_protocol::protocol::RolloutLine; + use codex_protocol::protocol::SessionMetaLine; + use std::fs; + + let temp_dir = TempDir::new()?; + let path = temp_dir.path().join("rollout.jsonl"); + + let conversation_id = ConversationId::from_string("bfd12a78-5900-467b-9bc5-d3d35df08191")?; + let timestamp = "2025-09-05T16:53:11.850Z".to_string(); + + let session_meta = SessionMeta { + id: conversation_id, + timestamp: timestamp.clone(), + model_provider: None, + ..SessionMeta::default() + }; + + let line = RolloutLine { + timestamp: timestamp.clone(), + item: RolloutItem::SessionMeta(SessionMetaLine { + meta: session_meta.clone(), + git: None, + }), + }; + + fs::write(&path, format!("{}\n", serde_json::to_string(&line)?))?; + + let summary = read_summary_from_rollout(path.as_path(), "fallback").await?; + + let expected = ConversationSummary { + conversation_id, + timestamp: Some(timestamp), + path: path.clone(), + preview: String::new(), + model_provider: "fallback".to_string(), + cwd: PathBuf::new(), + cli_version: String::new(), + source: SessionSource::VSCode, + git_info: None, + }; + + assert_eq!(summary, expected); Ok(()) } } diff --git a/codex-rs/app-server/src/fuzzy_file_search.rs b/codex-rs/app-server/src/fuzzy_file_search.rs index 6c83a0f4ec..fcb05852ba 100644 --- a/codex-rs/app-server/src/fuzzy_file_search.rs +++ b/codex-rs/app-server/src/fuzzy_file_search.rs @@ -46,6 +46,7 @@ pub(crate) async fn run_fuzzy_file_search( threads, cancel_flag, COMPUTE_INDICES, + true, ) { Ok(res) => Ok((root, res)), Err(err) => Err((root, err)), diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 75a8be0188..6ef986919f 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -1,32 +1,38 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] -use std::io::ErrorKind; -use std::io::Result as IoResult; -use std::path::PathBuf; - use codex_common::CliConfigOverrides; use codex_core::config::Config; use codex_core::config::ConfigOverrides; +use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge; +use std::io::ErrorKind; +use std::io::Result as IoResult; +use std::path::PathBuf; +use crate::message_processor::MessageProcessor; +use crate::outgoing_message::OutgoingMessage; +use crate::outgoing_message::OutgoingMessageSender; use codex_app_server_protocol::JSONRPCMessage; +use codex_feedback::CodexFeedback; use tokio::io::AsyncBufReadExt; use tokio::io::AsyncWriteExt; use tokio::io::BufReader; use tokio::io::{self}; use tokio::sync::mpsc; +use tracing::Level; use tracing::debug; use tracing::error; use tracing::info; use tracing_subscriber::EnvFilter; - -use crate::message_processor::MessageProcessor; -use crate::outgoing_message::OutgoingMessage; -use crate::outgoing_message::OutgoingMessageSender; +use tracing_subscriber::Layer; +use tracing_subscriber::filter::Targets; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; mod codex_message_processor; mod error_code; mod fuzzy_file_search; mod message_processor; +mod models; mod outgoing_message; /// Size of the bounded channels used to communicate between tasks. The value @@ -38,13 +44,6 @@ pub async fn run_main( codex_linux_sandbox_exe: Option, cli_config_overrides: CliConfigOverrides, ) -> IoResult<()> { - // Install a simple subscriber so `tracing` output is visible. Users can - // control the log level with `RUST_LOG`. - tracing_subscriber::fmt() - .with_writer(std::io::stderr) - .with_env_filter(EnvFilter::from_default_env()) - .init(); - // Set up channels. let (incoming_tx, mut incoming_rx) = mpsc::channel::(CHANNEL_CAPACITY); let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded_channel::(); @@ -86,6 +85,38 @@ pub async fn run_main( std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}")) })?; + let feedback = CodexFeedback::new(); + + let otel = + codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION")).map_err(|e| { + std::io::Error::new( + ErrorKind::InvalidData, + format!("error loading otel config: {e}"), + ) + })?; + + // Install a simple subscriber so `tracing` output is visible. Users can + // control the log level with `RUST_LOG`. + let stderr_fmt = tracing_subscriber::fmt::layer() + .with_writer(std::io::stderr) + .with_filter(EnvFilter::from_default_env()); + + let feedback_layer = tracing_subscriber::fmt::layer() + .with_writer(feedback.make_writer()) + .with_ansi(false) + .with_target(false) + .with_filter(Targets::new().with_default(Level::TRACE)); + + let _ = tracing_subscriber::registry() + .with(stderr_fmt) + .with(feedback_layer) + .with(otel.as_ref().map(|provider| { + OpenTelemetryTracingBridge::new(&provider.logger).with_filter( + tracing_subscriber::filter::filter_fn(codex_core::otel_init::codex_export_filter), + ) + })) + .try_init(); + // Task: process incoming messages. let processor_handle = tokio::spawn({ let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx); @@ -93,6 +124,7 @@ pub async fn run_main( outgoing_message_sender, codex_linux_sandbox_exe, std::sync::Arc::new(config), + feedback.clone(), ); async move { while let Some(msg) = incoming_rx.recv().await { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 15086c19e1..a97b037be0 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -17,6 +17,7 @@ use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::default_client::USER_AGENT_SUFFIX; use codex_core::default_client::get_codex_user_agent; +use codex_feedback::CodexFeedback; use codex_protocol::protocol::SessionSource; use std::sync::Arc; @@ -33,9 +34,14 @@ impl MessageProcessor { outgoing: OutgoingMessageSender, codex_linux_sandbox_exe: Option, config: Arc, + feedback: CodexFeedback, ) -> Self { let outgoing = Arc::new(outgoing); - let auth_manager = AuthManager::shared(config.codex_home.clone(), false); + let auth_manager = AuthManager::shared( + config.codex_home.clone(), + false, + config.cli_auth_credentials_store_mode, + ); let conversation_manager = Arc::new(ConversationManager::new( auth_manager.clone(), SessionSource::VSCode, @@ -46,6 +52,7 @@ impl MessageProcessor { outgoing.clone(), codex_linux_sandbox_exe, config, + feedback, ); Self { @@ -57,64 +64,79 @@ impl MessageProcessor { pub(crate) async fn process_request(&mut self, request: JSONRPCRequest) { let request_id = request.id.clone(); - if let Ok(request_json) = serde_json::to_value(request) - && let Ok(codex_request) = serde_json::from_value::(request_json) - { - match codex_request { - // Handle Initialize internally so CodexMessageProcessor does not have to concern - // itself with the `initialized` bool. - ClientRequest::Initialize { request_id, params } => { - if self.initialized { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "Already initialized".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } else { - let ClientInfo { - name, - title: _title, - version, - } = params.client_info; - let user_agent_suffix = format!("{name}; {version}"); - if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() { - *suffix = Some(user_agent_suffix); - } + let request_json = match serde_json::to_value(&request) { + Ok(request_json) => request_json, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("Invalid request: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; - let user_agent = get_codex_user_agent(); - let response = InitializeResponse { user_agent }; - self.outgoing.send_response(request_id, response).await; + let codex_request = match serde_json::from_value::(request_json) { + Ok(codex_request) => codex_request, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("Invalid request: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; - self.initialized = true; - return; - } - } - _ => { - if !self.initialized { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "Not initialized".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; + match codex_request { + // Handle Initialize internally so CodexMessageProcessor does not have to concern + // itself with the `initialized` bool. + ClientRequest::Initialize { request_id, params } => { + if self.initialized { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "Already initialized".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } else { + let ClientInfo { + name, + title: _title, + version, + } = params.client_info; + let user_agent_suffix = format!("{name}; {version}"); + if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() { + *suffix = Some(user_agent_suffix); } + + let user_agent = get_codex_user_agent(); + let response = InitializeResponse { user_agent }; + self.outgoing.send_response(request_id, response).await; + + self.initialized = true; + return; + } + } + _ => { + if !self.initialized { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "Not initialized".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; } } - - self.codex_message_processor - .process_request(codex_request) - .await; - } else { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: "Invalid request".to_string(), - data: None, - }; - self.outgoing.send_error(request_id, error).await; } + + self.codex_message_processor + .process_request(codex_request) + .await; } pub(crate) async fn process_notification(&self, notification: JSONRPCNotification) { diff --git a/codex-rs/app-server/src/models.rs b/codex-rs/app-server/src/models.rs new file mode 100644 index 0000000000..d03795c2d4 --- /dev/null +++ b/codex-rs/app-server/src/models.rs @@ -0,0 +1,39 @@ +use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::Model; +use codex_app_server_protocol::ReasoningEffortOption; +use codex_common::model_presets::ModelPreset; +use codex_common::model_presets::ReasoningEffortPreset; +use codex_common::model_presets::builtin_model_presets; + +pub fn supported_models(auth_mode: Option) -> Vec { + builtin_model_presets(auth_mode) + .into_iter() + .map(model_from_preset) + .collect() +} + +fn model_from_preset(preset: ModelPreset) -> Model { + Model { + id: preset.id.to_string(), + model: preset.model.to_string(), + display_name: preset.display_name.to_string(), + description: preset.description.to_string(), + supported_reasoning_efforts: reasoning_efforts_from_preset( + preset.supported_reasoning_efforts, + ), + default_reasoning_effort: preset.default_reasoning_effort, + is_default: preset.is_default, + } +} + +fn reasoning_efforts_from_preset( + efforts: &'static [ReasoningEffortPreset], +) -> Vec { + efforts + .iter() + .map(|preset| ReasoningEffortOption { + reasoning_effort: preset.effort, + description: preset.description.to_string(), + }) + .collect() +} diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index 96a2c5a96c..f0ee6cf939 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -141,7 +141,13 @@ pub(crate) struct OutgoingError { #[cfg(test)] mod tests { + use codex_app_server_protocol::AccountLoginCompletedNotification; + use codex_app_server_protocol::AccountRateLimitsUpdatedNotification; + use codex_app_server_protocol::AccountUpdatedNotification; + use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::LoginChatGptCompleteNotification; + use codex_app_server_protocol::RateLimitSnapshot; + use codex_app_server_protocol::RateLimitWindow; use pretty_assertions::assert_eq; use serde_json::json; use uuid::Uuid; @@ -164,6 +170,7 @@ mod tests { "params": { "loginId": Uuid::nil(), "success": true, + "error": null, }, }), serde_json::to_value(jsonrpc_notification) @@ -171,4 +178,84 @@ mod tests { "ensure the strum macros serialize the method field correctly" ); } + + #[test] + fn verify_account_login_completed_notification_serialization() { + let notification = + ServerNotification::AccountLoginCompleted(AccountLoginCompletedNotification { + login_id: Some(Uuid::nil().to_string()), + success: true, + error: None, + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "account/login/completed", + "params": { + "loginId": Uuid::nil().to_string(), + "success": true, + "error": null, + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } + + #[test] + fn verify_account_rate_limits_notification_serialization() { + let notification = + ServerNotification::AccountRateLimitsUpdated(AccountRateLimitsUpdatedNotification { + rate_limits: RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 25, + window_duration_mins: Some(15), + resets_at: Some(123), + }), + secondary: None, + }, + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "account/rateLimits/updated", + "params": { + "rateLimits": { + "primary": { + "usedPercent": 25, + "windowDurationMins": 15, + "resetsAt": 123 + }, + "secondary": null + } + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } + + #[test] + fn verify_account_updated_notification_serialization() { + let notification = ServerNotification::AccountUpdated(AccountUpdatedNotification { + auth_mode: Some(AuthMode::ApiKey), + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "account/updated", + "params": { + "authMode": "apikey" + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } } diff --git a/codex-rs/app-server/tests/common/Cargo.toml b/codex-rs/app-server/tests/common/Cargo.toml index 306b1e187e..6240f755ee 100644 --- a/codex-rs/app-server/tests/common/Cargo.toml +++ b/codex-rs/app-server/tests/common/Cargo.toml @@ -9,7 +9,11 @@ path = "lib.rs" [dependencies] anyhow = { workspace = true } assert_cmd = { workspace = true } +base64 = { workspace = true } +chrono = { workspace = true } codex-app-server-protocol = { workspace = true } +codex-core = { workspace = true } +codex-protocol = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true, features = [ @@ -18,4 +22,5 @@ tokio = { workspace = true, features = [ "process", "rt-multi-thread", ] } +uuid = { workspace = true } wiremock = { workspace = true } diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs new file mode 100644 index 0000000000..071a920b89 --- /dev/null +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -0,0 +1,135 @@ +use std::path::Path; + +use anyhow::Context; +use anyhow::Result; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use chrono::DateTime; +use chrono::Utc; +use codex_core::auth::AuthCredentialsStoreMode; +use codex_core::auth::AuthDotJson; +use codex_core::auth::save_auth; +use codex_core::token_data::TokenData; +use codex_core::token_data::parse_id_token; +use serde_json::json; + +/// Builder for writing a fake ChatGPT auth.json in tests. +#[derive(Debug, Clone)] +pub struct ChatGptAuthFixture { + access_token: String, + refresh_token: String, + account_id: Option, + claims: ChatGptIdTokenClaims, + last_refresh: Option>>, +} + +impl ChatGptAuthFixture { + pub fn new(access_token: impl Into) -> Self { + Self { + access_token: access_token.into(), + refresh_token: "refresh-token".to_string(), + account_id: None, + claims: ChatGptIdTokenClaims::default(), + last_refresh: None, + } + } + + pub fn refresh_token(mut self, refresh_token: impl Into) -> Self { + self.refresh_token = refresh_token.into(); + self + } + + pub fn account_id(mut self, account_id: impl Into) -> Self { + self.account_id = Some(account_id.into()); + self + } + + pub fn plan_type(mut self, plan_type: impl Into) -> Self { + self.claims.plan_type = Some(plan_type.into()); + self + } + + pub fn email(mut self, email: impl Into) -> Self { + self.claims.email = Some(email.into()); + self + } + + pub fn last_refresh(mut self, last_refresh: Option>) -> Self { + self.last_refresh = Some(last_refresh); + self + } + + pub fn claims(mut self, claims: ChatGptIdTokenClaims) -> Self { + self.claims = claims; + self + } +} + +#[derive(Debug, Clone, Default)] +pub struct ChatGptIdTokenClaims { + pub email: Option, + pub plan_type: Option, +} + +impl ChatGptIdTokenClaims { + pub fn new() -> Self { + Self::default() + } + + pub fn email(mut self, email: impl Into) -> Self { + self.email = Some(email.into()); + self + } + + pub fn plan_type(mut self, plan_type: impl Into) -> Self { + self.plan_type = Some(plan_type.into()); + self + } +} + +pub fn encode_id_token(claims: &ChatGptIdTokenClaims) -> Result { + let header = json!({ "alg": "none", "typ": "JWT" }); + let mut payload = serde_json::Map::new(); + if let Some(email) = &claims.email { + payload.insert("email".to_string(), json!(email)); + } + if let Some(plan_type) = &claims.plan_type { + payload.insert( + "https://api.openai.com/auth".to_string(), + json!({ "chatgpt_plan_type": plan_type }), + ); + } + let payload = serde_json::Value::Object(payload); + + let header_b64 = + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).context("serialize jwt header")?); + let payload_b64 = + URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).context("serialize jwt payload")?); + let signature_b64 = URL_SAFE_NO_PAD.encode(b"signature"); + Ok(format!("{header_b64}.{payload_b64}.{signature_b64}")) +} + +pub fn write_chatgpt_auth( + codex_home: &Path, + fixture: ChatGptAuthFixture, + cli_auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> Result<()> { + let id_token_raw = encode_id_token(&fixture.claims)?; + let id_token = parse_id_token(&id_token_raw).context("parse id token")?; + let tokens = TokenData { + id_token, + access_token: fixture.access_token, + refresh_token: fixture.refresh_token, + account_id: fixture.account_id, + }; + + let last_refresh = fixture.last_refresh.unwrap_or_else(|| Some(Utc::now())); + + let auth = AuthDotJson { + openai_api_key: None, + tokens: Some(tokens), + last_refresh, + }; + + save_auth(codex_home, &auth, cli_auth_credentials_store_mode).context("write auth.json") +} diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index 2acb52de69..dc3d24ccae 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -1,13 +1,21 @@ +mod auth_fixtures; mod mcp_process; mod mock_model_server; mod responses; +mod rollout; +pub use auth_fixtures::ChatGptAuthFixture; +pub use auth_fixtures::ChatGptIdTokenClaims; +pub use auth_fixtures::encode_id_token; +pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; pub use mcp_process::McpProcess; pub use mock_model_server::create_mock_chat_completions_server; +pub use mock_model_server::create_mock_chat_completions_server_unchecked; pub use responses::create_apply_patch_sse_response; pub use responses::create_final_assistant_message_sse_response; pub use responses::create_shell_sse_response; +pub use rollout::create_fake_rollout; use serde::de::DeserializeOwned; pub fn to_response(response: JSONRPCResponse) -> anyhow::Result { diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 29768df81c..75851eda2c 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -14,28 +14,37 @@ use anyhow::Context; use assert_cmd::prelude::*; use codex_app_server_protocol::AddConversationListenerParams; use codex_app_server_protocol::ArchiveConversationParams; +use codex_app_server_protocol::CancelLoginAccountParams; use codex_app_server_protocol::CancelLoginChatGptParams; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; +use codex_app_server_protocol::FeedbackUploadParams; +use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::InterruptConversationParams; -use codex_app_server_protocol::ListConversationsParams; -use codex_app_server_protocol::LoginApiKeyParams; -use codex_app_server_protocol::NewConversationParams; -use codex_app_server_protocol::RemoveConversationListenerParams; -use codex_app_server_protocol::ResumeConversationParams; -use codex_app_server_protocol::SendUserMessageParams; -use codex_app_server_protocol::SendUserTurnParams; -use codex_app_server_protocol::ServerRequest; -use codex_app_server_protocol::SetDefaultModelParams; - use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCRequest; use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::ListConversationsParams; +use codex_app_server_protocol::LoginApiKeyParams; +use codex_app_server_protocol::ModelListParams; +use codex_app_server_protocol::NewConversationParams; +use codex_app_server_protocol::RemoveConversationListenerParams; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ResumeConversationParams; +use codex_app_server_protocol::SendUserMessageParams; +use codex_app_server_protocol::SendUserTurnParams; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::SetDefaultModelParams; +use codex_app_server_protocol::ThreadArchiveParams; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::TurnInterruptParams; +use codex_app_server_protocol::TurnStartParams; use std::process::Command as StdCommand; use tokio::process::Command; @@ -236,6 +245,29 @@ impl McpProcess { self.send_request("getUserAgent", None).await } + /// Send an `account/rateLimits/read` JSON-RPC request. + pub async fn send_get_account_rate_limits_request(&mut self) -> anyhow::Result { + self.send_request("account/rateLimits/read", None).await + } + + /// Send an `account/read` JSON-RPC request. + pub async fn send_get_account_request( + &mut self, + params: GetAccountParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("account/read", params).await + } + + /// Send a `feedback/upload` JSON-RPC request. + pub async fn send_feedback_upload_request( + &mut self, + params: FeedbackUploadParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("feedback/upload", params).await + } + /// Send a `userInfo` JSON-RPC request. pub async fn send_user_info_request(&mut self) -> anyhow::Result { self.send_request("userInfo", None).await @@ -259,6 +291,51 @@ impl McpProcess { self.send_request("listConversations", params).await } + /// Send a `thread/start` JSON-RPC request. + pub async fn send_thread_start_request( + &mut self, + params: ThreadStartParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/start", params).await + } + + /// Send a `thread/resume` JSON-RPC request. + pub async fn send_thread_resume_request( + &mut self, + params: ThreadResumeParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/resume", params).await + } + + /// Send a `thread/archive` JSON-RPC request. + pub async fn send_thread_archive_request( + &mut self, + params: ThreadArchiveParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/archive", params).await + } + + /// Send a `thread/list` JSON-RPC request. + pub async fn send_thread_list_request( + &mut self, + params: ThreadListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/list", params).await + } + + /// Send a `model/list` JSON-RPC request. + pub async fn send_list_models_request( + &mut self, + params: ModelListParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("model/list", params).await + } + /// Send a `resumeConversation` JSON-RPC request. pub async fn send_resume_conversation_request( &mut self, @@ -282,6 +359,24 @@ impl McpProcess { self.send_request("loginChatGpt", None).await } + /// Send a `turn/start` JSON-RPC request (v2). + pub async fn send_turn_start_request( + &mut self, + params: TurnStartParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("turn/start", params).await + } + + /// Send a `turn/interrupt` JSON-RPC request (v2). + pub async fn send_turn_interrupt_request( + &mut self, + params: TurnInterruptParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("turn/interrupt", params).await + } + /// Send a `cancelLoginChatGpt` JSON-RPC request. pub async fn send_cancel_login_chat_gpt_request( &mut self, @@ -296,6 +391,40 @@ impl McpProcess { self.send_request("logoutChatGpt", None).await } + /// Send an `account/logout` JSON-RPC request. + pub async fn send_logout_account_request(&mut self) -> anyhow::Result { + self.send_request("account/logout", None).await + } + + /// Send an `account/login/start` JSON-RPC request for API key login. + pub async fn send_login_account_api_key_request( + &mut self, + api_key: &str, + ) -> anyhow::Result { + let params = serde_json::json!({ + "type": "apiKey", + "apiKey": api_key, + }); + self.send_request("account/login/start", Some(params)).await + } + + /// Send an `account/login/start` JSON-RPC request for ChatGPT login. + pub async fn send_login_account_chatgpt_request(&mut self) -> anyhow::Result { + let params = serde_json::json!({ + "type": "chatgpt" + }); + self.send_request("account/login/start", Some(params)).await + } + + /// Send an `account/login/cancel` JSON-RPC request. + pub async fn send_cancel_login_account_request( + &mut self, + params: CancelLoginAccountParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("account/login/cancel", params).await + } + /// Send a `fuzzyFileSearch` JSON-RPC request. pub async fn send_fuzzy_file_search_request( &mut self, diff --git a/codex-rs/app-server/tests/common/mock_model_server.rs b/codex-rs/app-server/tests/common/mock_model_server.rs index be7f3eb5b3..08765338a9 100644 --- a/codex-rs/app-server/tests/common/mock_model_server.rs +++ b/codex-rs/app-server/tests/common/mock_model_server.rs @@ -29,6 +29,25 @@ pub async fn create_mock_chat_completions_server(responses: Vec) -> Mock server } +/// Same as `create_mock_chat_completions_server` but does not enforce an +/// expectation on the number of calls. +pub async fn create_mock_chat_completions_server_unchecked(responses: Vec) -> MockServer { + let server = MockServer::start().await; + + let seq_responder = SeqResponder { + num_calls: AtomicUsize::new(0), + responses, + }; + + Mock::given(method("POST")) + .and(path("/v1/chat/completions")) + .respond_with(seq_responder) + .mount(&server) + .await; + + server +} + struct SeqResponder { num_calls: AtomicUsize, responses: Vec, diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs new file mode 100644 index 0000000000..c8197a046d --- /dev/null +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -0,0 +1,82 @@ +use anyhow::Result; +use codex_protocol::ConversationId; +use codex_protocol::protocol::SessionMeta; +use codex_protocol::protocol::SessionSource; +use serde_json::json; +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use uuid::Uuid; + +/// Create a minimal rollout file under `CODEX_HOME/sessions/YYYY/MM/DD/`. +/// +/// - `filename_ts` is the filename timestamp component in `YYYY-MM-DDThh-mm-ss` format. +/// - `meta_rfc3339` is the envelope timestamp used in JSON lines. +/// - `preview` is the user message preview text. +/// - `model_provider` optionally sets the provider in the session meta payload. +/// +/// Returns the generated conversation/session UUID as a string. +pub fn create_fake_rollout( + codex_home: &Path, + filename_ts: &str, + meta_rfc3339: &str, + preview: &str, + model_provider: Option<&str>, +) -> Result { + let uuid = Uuid::new_v4(); + let uuid_str = uuid.to_string(); + let conversation_id = ConversationId::from_string(&uuid_str)?; + + // sessions/YYYY/MM/DD derived from filename_ts (YYYY-MM-DDThh-mm-ss) + let year = &filename_ts[0..4]; + let month = &filename_ts[5..7]; + let day = &filename_ts[8..10]; + let dir = codex_home.join("sessions").join(year).join(month).join(day); + fs::create_dir_all(&dir)?; + + let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl")); + + // Build JSONL lines + let payload = serde_json::to_value(SessionMeta { + id: conversation_id, + timestamp: meta_rfc3339.to_string(), + cwd: PathBuf::from("/"), + originator: "codex".to_string(), + cli_version: "0.0.0".to_string(), + instructions: None, + source: SessionSource::Cli, + model_provider: model_provider.map(str::to_string), + })?; + + let lines = [ + json!({ + "timestamp": meta_rfc3339, + "type": "session_meta", + "payload": payload + }) + .to_string(), + json!({ + "timestamp": meta_rfc3339, + "type":"response_item", + "payload": { + "type":"message", + "role":"user", + "content":[{"type":"input_text","text": preview}] + } + }) + .to_string(), + json!({ + "timestamp": meta_rfc3339, + "type":"event_msg", + "payload": { + "type":"user_message", + "message": preview, + "kind": "plain" + } + }) + .to_string(), + ]; + + fs::write(file_path, lines.join("\n") + "\n")?; + Ok(uuid_str) +} diff --git a/codex-rs/app-server/tests/suite/archive_conversation.rs b/codex-rs/app-server/tests/suite/archive_conversation.rs index 6dcfefdbb8..b6e85936fb 100644 --- a/codex-rs/app-server/tests/suite/archive_conversation.rs +++ b/codex-rs/app-server/tests/suite/archive_conversation.rs @@ -1,5 +1,4 @@ -use std::path::Path; - +use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; use codex_app_server_protocol::ArchiveConversationParams; @@ -9,45 +8,37 @@ use codex_app_server_protocol::NewConversationParams; use codex_app_server_protocol::NewConversationResponse; use codex_app_server_protocol::RequestId; use codex_core::ARCHIVED_SESSIONS_SUBDIR; +use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; -const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn archive_conversation_moves_rollout_into_archived_directory() { - let codex_home = TempDir::new().expect("create temp dir"); - create_config_toml(codex_home.path()).expect("write config.toml"); +async fn archive_conversation_moves_rollout_into_archived_directory() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; - let mut mcp = McpProcess::new(codex_home.path()) - .await - .expect("spawn mcp process"); - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) - .await - .expect("initialize timeout") - .expect("initialize request"); + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let new_request_id = mcp .send_new_conversation_request(NewConversationParams { model: Some("mock-model".to_string()), ..Default::default() }) - .await - .expect("send newConversation"); + .await?; let new_response: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(new_request_id)), ) - .await - .expect("newConversation timeout") - .expect("newConversation response"); + .await??; let NewConversationResponse { conversation_id, rollout_path, .. - } = to_response::(new_response) - .expect("deserialize newConversation response"); + } = to_response::(new_response)?; assert!( rollout_path.exists(), @@ -60,19 +51,15 @@ async fn archive_conversation_moves_rollout_into_archived_directory() { conversation_id, rollout_path: rollout_path.clone(), }) - .await - .expect("send archiveConversation"); + .await?; let archive_response: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(archive_request_id)), ) - .await - .expect("archiveConversation timeout") - .expect("archiveConversation response"); + .await??; let _: ArchiveConversationResponse = - to_response::(archive_response) - .expect("deserialize archiveConversation response"); + to_response::(archive_response)?; let archived_directory = codex_home.path().join(ARCHIVED_SESSIONS_SUBDIR); let archived_rollout_path = @@ -90,6 +77,8 @@ async fn archive_conversation_moves_rollout_into_archived_directory() { "expected archived rollout path {} to exist", archived_rollout_path.display() ); + + Ok(()) } fn create_config_toml(codex_home: &Path) -> std::io::Result<()> { diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index f45a27fda0..72912362fe 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -1,15 +1,16 @@ -use std::path::Path; - +use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; use codex_app_server_protocol::AuthMode; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; +use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginApiKeyParams; use codex_app_server_protocol::LoginApiKeyResponse; use codex_app_server_protocol::RequestId; use pretty_assertions::assert_eq; +use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; @@ -57,125 +58,112 @@ sandbox_mode = "danger-full-access" ) } -async fn login_with_api_key_via_request(mcp: &mut McpProcess, api_key: &str) { +fn create_config_toml_forced_login(codex_home: &Path, forced_method: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + let contents = format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "danger-full-access" +forced_login_method = "{forced_method}" +"# + ); + std::fs::write(config_toml, contents) +} + +async fn login_with_api_key_via_request(mcp: &mut McpProcess, api_key: &str) -> Result<()> { let request_id = mcp .send_login_api_key_request(LoginApiKeyParams { api_key: api_key.to_string(), }) - .await - .unwrap_or_else(|e| panic!("send loginApiKey: {e}")); + .await?; let resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) - .await - .unwrap_or_else(|e| panic!("loginApiKey timeout: {e}")) - .unwrap_or_else(|e| panic!("loginApiKey response: {e}")); - let _: LoginApiKeyResponse = - to_response(resp).unwrap_or_else(|e| panic!("deserialize login response: {e}")); + .await??; + let _: LoginApiKeyResponse = to_response(resp)?; + Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn get_auth_status_no_auth() { - let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); - create_config_toml(codex_home.path()).unwrap_or_else(|err| panic!("write config.toml: {err}")); +async fn get_auth_status_no_auth() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]) - .await - .expect("spawn mcp process"); - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) - .await - .expect("init timeout") - .expect("init failed"); + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let request_id = mcp .send_get_auth_status_request(GetAuthStatusParams { include_token: Some(true), refresh_token: Some(false), }) - .await - .expect("send getAuthStatus"); + .await?; let resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) - .await - .expect("getAuthStatus timeout") - .expect("getAuthStatus response"); - let status: GetAuthStatusResponse = to_response(resp).expect("deserialize status"); + .await??; + let status: GetAuthStatusResponse = to_response(resp)?; assert_eq!(status.auth_method, None, "expected no auth method"); assert_eq!(status.auth_token, None, "expected no token"); + Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn get_auth_status_with_api_key() { - let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); - create_config_toml(codex_home.path()).unwrap_or_else(|err| panic!("write config.toml: {err}")); +async fn get_auth_status_with_api_key() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; - let mut mcp = McpProcess::new(codex_home.path()) - .await - .expect("spawn mcp process"); - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) - .await - .expect("init timeout") - .expect("init failed"); + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - login_with_api_key_via_request(&mut mcp, "sk-test-key").await; + login_with_api_key_via_request(&mut mcp, "sk-test-key").await?; let request_id = mcp .send_get_auth_status_request(GetAuthStatusParams { include_token: Some(true), refresh_token: Some(false), }) - .await - .expect("send getAuthStatus"); + .await?; let resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) - .await - .expect("getAuthStatus timeout") - .expect("getAuthStatus response"); - let status: GetAuthStatusResponse = to_response(resp).expect("deserialize status"); + .await??; + let status: GetAuthStatusResponse = to_response(resp)?; assert_eq!(status.auth_method, Some(AuthMode::ApiKey)); assert_eq!(status.auth_token, Some("sk-test-key".to_string())); + Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn get_auth_status_with_api_key_when_auth_not_required() { - let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); - create_config_toml_custom_provider(codex_home.path(), false) - .unwrap_or_else(|err| panic!("write config.toml: {err}")); +async fn get_auth_status_with_api_key_when_auth_not_required() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml_custom_provider(codex_home.path(), false)?; - let mut mcp = McpProcess::new(codex_home.path()) - .await - .expect("spawn mcp process"); - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) - .await - .expect("init timeout") - .expect("init failed"); + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - login_with_api_key_via_request(&mut mcp, "sk-test-key").await; + login_with_api_key_via_request(&mut mcp, "sk-test-key").await?; let request_id = mcp .send_get_auth_status_request(GetAuthStatusParams { include_token: Some(true), refresh_token: Some(false), }) - .await - .expect("send getAuthStatus"); + .await?; let resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) - .await - .expect("getAuthStatus timeout") - .expect("getAuthStatus response"); - let status: GetAuthStatusResponse = to_response(resp).expect("deserialize status"); + .await??; + let status: GetAuthStatusResponse = to_response(resp)?; assert_eq!(status.auth_method, None, "expected no auth method"); assert_eq!(status.auth_token, None, "expected no token"); assert_eq!( @@ -183,41 +171,60 @@ async fn get_auth_status_with_api_key_when_auth_not_required() { Some(false), "requires_openai_auth should be false", ); + Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn get_auth_status_with_api_key_no_include_token() { - let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); - create_config_toml(codex_home.path()).unwrap_or_else(|err| panic!("write config.toml: {err}")); +async fn get_auth_status_with_api_key_no_include_token() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; - let mut mcp = McpProcess::new(codex_home.path()) - .await - .expect("spawn mcp process"); - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) - .await - .expect("init timeout") - .expect("init failed"); + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - login_with_api_key_via_request(&mut mcp, "sk-test-key").await; + login_with_api_key_via_request(&mut mcp, "sk-test-key").await?; // Build params via struct so None field is omitted in wire JSON. let params = GetAuthStatusParams { include_token: None, refresh_token: Some(false), }; - let request_id = mcp - .send_get_auth_status_request(params) - .await - .expect("send getAuthStatus"); + let request_id = mcp.send_get_auth_status_request(params).await?; let resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) - .await - .expect("getAuthStatus timeout") - .expect("getAuthStatus response"); - let status: GetAuthStatusResponse = to_response(resp).expect("deserialize status"); + .await??; + let status: GetAuthStatusResponse = to_response(resp)?; assert_eq!(status.auth_method, Some(AuthMode::ApiKey)); assert!(status.auth_token.is_none(), "token must be omitted"); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn login_api_key_rejected_when_forced_chatgpt() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml_forced_login(codex_home.path(), "chatgpt")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_login_api_key_request(LoginApiKeyParams { + api_key: "sk-test-key".to_string(), + }) + .await?; + + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!( + err.error.message, + "API key login is disabled. Use ChatGPT login instead." + ); + Ok(()) } diff --git a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs index 27731f8430..1feda42841 100644 --- a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs +++ b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs @@ -1,5 +1,4 @@ -use std::path::Path; - +use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_chat_completions_server; @@ -30,29 +29,29 @@ use codex_protocol::config_types::SandboxMode; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::InputMessageKind; use pretty_assertions::assert_eq; use std::env; +use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_codex_jsonrpc_conversation_flow() { +async fn test_codex_jsonrpc_conversation_flow() -> Result<()> { if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { println!( "Skipping test because it cannot execute when network is disabled in a Codex sandbox." ); - return; + return Ok(()); } - let tmp = TempDir::new().expect("tmp dir"); + let tmp = TempDir::new()?; // Temporary Codex home with config pointing at the mock server. let codex_home = tmp.path().join("codex_home"); - std::fs::create_dir(&codex_home).expect("create codex home dir"); + std::fs::create_dir(&codex_home)?; let working_directory = tmp.path().join("workdir"); - std::fs::create_dir(&working_directory).expect("create working directory"); + std::fs::create_dir(&working_directory)?; // Create a mock model server that immediately ends each turn. // Two turns are expected: initial session configure + one user message. @@ -62,20 +61,15 @@ async fn test_codex_jsonrpc_conversation_flow() { Some(&working_directory), Some(5000), "call1234", - ) - .expect("create shell sse response"), - create_final_assistant_message_sse_response("Enjoy your new git repo!") - .expect("create final assistant message"), + )?, + create_final_assistant_message_sse_response("Enjoy your new git repo!")?, ]; let server = create_mock_chat_completions_server(responses).await; - create_config_toml(&codex_home, &server.uri()).expect("write config"); + create_config_toml(&codex_home, &server.uri())?; // Start MCP server and initialize. - let mut mcp = McpProcess::new(&codex_home).await.expect("spawn mcp"); - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) - .await - .expect("init timeout") - .expect("init error"); + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // 1) newConversation let new_conv_id = mcp @@ -83,17 +77,13 @@ async fn test_codex_jsonrpc_conversation_flow() { cwd: Some(working_directory.to_string_lossy().into_owned()), ..Default::default() }) - .await - .expect("send newConversation"); + .await?; let new_conv_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)), ) - .await - .expect("newConversation timeout") - .expect("newConversation resp"); - let new_conv_resp = to_response::(new_conv_resp) - .expect("deserialize newConversation response"); + .await??; + let new_conv_resp = to_response::(new_conv_resp)?; let NewConversationResponse { conversation_id, model, @@ -104,19 +94,18 @@ async fn test_codex_jsonrpc_conversation_flow() { // 2) addConversationListener let add_listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { conversation_id }) - .await - .expect("send addConversationListener"); + .send_add_conversation_listener_request(AddConversationListenerParams { + conversation_id, + experimental_raw_events: false, + }) + .await?; let add_listener_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)), ) - .await - .expect("addConversationListener timeout") - .expect("addConversationListener resp"); + .await??; let AddConversationSubscriptionResponse { subscription_id } = - to_response::(add_listener_resp) - .expect("deserialize addConversationListener response"); + to_response::(add_listener_resp)?; // 3) sendUserMessage (should trigger notifications; we only validate an OK response) let send_user_id = mcp @@ -126,17 +115,13 @@ async fn test_codex_jsonrpc_conversation_flow() { text: "text".to_string(), }], }) - .await - .expect("send sendUserMessage"); + .await?; let send_user_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(send_user_id)), ) - .await - .expect("sendUserMessage timeout") - .expect("sendUserMessage resp"); - let SendUserMessageResponse {} = to_response::(send_user_resp) - .expect("deserialize sendUserMessage response"); + .await??; + let SendUserMessageResponse {} = to_response::(send_user_resp)?; // Verify the task_finished notification is received. // Note this also ensures that the final request to the server was made. @@ -144,9 +129,7 @@ async fn test_codex_jsonrpc_conversation_flow() { DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("codex/event/task_complete"), ) - .await - .expect("task_finished_notification timeout") - .expect("task_finished_notification resp"); + .await??; let serde_json::Value::Object(map) = task_finished_notification .params .expect("notification should have params") @@ -164,33 +147,31 @@ async fn test_codex_jsonrpc_conversation_flow() { .send_remove_conversation_listener_request(RemoveConversationListenerParams { subscription_id, }) - .await - .expect("send removeConversationListener"); + .await?; let remove_listener_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(remove_listener_id)), ) - .await - .expect("removeConversationListener timeout") - .expect("removeConversationListener resp"); - let RemoveConversationSubscriptionResponse {} = - to_response(remove_listener_resp).expect("deserialize removeConversationListener response"); + .await??; + let RemoveConversationSubscriptionResponse {} = to_response(remove_listener_resp)?; + + Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_send_user_turn_changes_approval_policy_behavior() { +async fn test_send_user_turn_changes_approval_policy_behavior() -> Result<()> { if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { println!( "Skipping test because it cannot execute when network is disabled in a Codex sandbox." ); - return; + return Ok(()); } - let tmp = TempDir::new().expect("tmp dir"); + let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); - std::fs::create_dir(&codex_home).expect("create codex home dir"); + std::fs::create_dir(&codex_home)?; let working_directory = tmp.path().join("workdir"); - std::fs::create_dir(&working_directory).expect("create working directory"); + std::fs::create_dir(&working_directory)?; // Mock server will request a python shell call for the first and second turn, then finish. let responses = vec![ @@ -203,10 +184,8 @@ async fn test_send_user_turn_changes_approval_policy_behavior() { Some(&working_directory), Some(5000), "call1", - ) - .expect("create first shell sse response"), - create_final_assistant_message_sse_response("done 1") - .expect("create final assistant message 1"), + )?, + create_final_assistant_message_sse_response("done 1")?, create_shell_sse_response( vec![ "python3".to_string(), @@ -216,20 +195,15 @@ async fn test_send_user_turn_changes_approval_policy_behavior() { Some(&working_directory), Some(5000), "call2", - ) - .expect("create second shell sse response"), - create_final_assistant_message_sse_response("done 2") - .expect("create final assistant message 2"), + )?, + create_final_assistant_message_sse_response("done 2")?, ]; let server = create_mock_chat_completions_server(responses).await; - create_config_toml(&codex_home, &server.uri()).expect("write config"); + create_config_toml(&codex_home, &server.uri())?; // Start MCP server and initialize. - let mut mcp = McpProcess::new(&codex_home).await.expect("spawn mcp"); - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) - .await - .expect("init timeout") - .expect("init error"); + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // 1) Start conversation with approval_policy=untrusted let new_conv_id = mcp @@ -237,36 +211,30 @@ async fn test_send_user_turn_changes_approval_policy_behavior() { cwd: Some(working_directory.to_string_lossy().into_owned()), ..Default::default() }) - .await - .expect("send newConversation"); + .await?; let new_conv_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)), ) - .await - .expect("newConversation timeout") - .expect("newConversation resp"); + .await??; let NewConversationResponse { conversation_id, .. - } = to_response::(new_conv_resp) - .expect("deserialize newConversation response"); + } = to_response::(new_conv_resp)?; // 2) addConversationListener let add_listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { conversation_id }) - .await - .expect("send addConversationListener"); - let _: AddConversationSubscriptionResponse = - to_response::( - timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)), - ) - .await - .expect("addConversationListener timeout") - .expect("addConversationListener resp"), + .send_add_conversation_listener_request(AddConversationListenerParams { + conversation_id, + experimental_raw_events: false, + }) + .await?; + let _: AddConversationSubscriptionResponse = to_response::( + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)), ) - .expect("deserialize addConversationListener response"); + .await??, + )?; // 3) sendUserMessage triggers a shell call; approval policy is Untrusted so we should get an elicitation let send_user_id = mcp @@ -276,27 +244,21 @@ async fn test_send_user_turn_changes_approval_policy_behavior() { text: "run python".to_string(), }], }) - .await - .expect("send sendUserMessage"); + .await?; let _send_user_resp: SendUserMessageResponse = to_response::( timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(send_user_id)), ) - .await - .expect("sendUserMessage timeout") - .expect("sendUserMessage resp"), - ) - .expect("deserialize sendUserMessage response"); + .await??, + )?; // Expect an ExecCommandApproval request (elicitation) let request = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_request_message(), ) - .await - .expect("waiting for exec approval request timeout") - .expect("exec approval request"); + .await??; let ServerRequest::ExecCommandApproval { request_id, params } = request else { panic!("expected ExecCommandApproval request, got: {request:?}"); }; @@ -312,6 +274,7 @@ async fn test_send_user_turn_changes_approval_policy_behavior() { ], cwd: working_directory.clone(), reason: None, + risk: None, parsed_cmd: vec![ParsedCommand::Unknown { cmd: "python3 -c 'print(42)'".to_string() }], @@ -324,17 +287,14 @@ async fn test_send_user_turn_changes_approval_policy_behavior() { request_id, serde_json::json!({ "decision": codex_core::protocol::ReviewDecision::Approved }), ) - .await - .expect("send approval response"); + .await?; // Wait for first TaskComplete let _ = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("codex/event/task_complete"), ) - .await - .expect("task_complete 1 timeout") - .expect("task_complete 1 notification"); + .await??; // 4) sendUserTurn with approval_policy=never should run without elicitation let send_turn_id = mcp @@ -350,19 +310,15 @@ async fn test_send_user_turn_changes_approval_policy_behavior() { effort: Some(ReasoningEffort::Medium), summary: ReasoningSummary::Auto, }) - .await - .expect("send sendUserTurn"); + .await?; // Acknowledge sendUserTurn let _send_turn_resp: SendUserTurnResponse = to_response::( timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(send_turn_id)), ) - .await - .expect("sendUserTurn timeout") - .expect("sendUserTurn resp"), - ) - .expect("deserialize sendUserTurn response"); + .await??, + )?; // Ensure we do NOT receive an ExecCommandApproval request before the task completes. // If any Request is seen while waiting for task_complete, the helper will error and the test fails. @@ -370,31 +326,31 @@ async fn test_send_user_turn_changes_approval_policy_behavior() { DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("codex/event/task_complete"), ) - .await - .expect("task_complete 2 timeout") - .expect("task_complete 2 notification"); + .await??; + + Ok(()) } // Helper: minimal config.toml pointing at mock provider. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() { +async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() -> Result<()> { if env::var(CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR).is_ok() { println!( "Skipping test because it cannot execute when network is disabled in a Codex sandbox." ); - return; + return Ok(()); } - let tmp = TempDir::new().expect("tmp dir"); + let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); - std::fs::create_dir(&codex_home).expect("create codex home dir"); + std::fs::create_dir(&codex_home)?; let workspace_root = tmp.path().join("workspace"); - std::fs::create_dir(&workspace_root).expect("create workspace root"); + std::fs::create_dir(&workspace_root)?; let first_cwd = workspace_root.join("turn1"); let second_cwd = workspace_root.join("turn2"); - std::fs::create_dir(&first_cwd).expect("create first cwd"); - std::fs::create_dir(&second_cwd).expect("create second cwd"); + std::fs::create_dir(&first_cwd)?; + std::fs::create_dir(&second_cwd)?; let responses = vec![ create_shell_sse_response( @@ -406,10 +362,8 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() { None, Some(5000), "call-first", - ) - .expect("create first shell response"), - create_final_assistant_message_sse_response("done first") - .expect("create first final assistant message"), + )?, + create_final_assistant_message_sse_response("done first")?, create_shell_sse_response( vec![ "bash".to_string(), @@ -419,21 +373,14 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() { None, Some(5000), "call-second", - ) - .expect("create second shell response"), - create_final_assistant_message_sse_response("done second") - .expect("create second final assistant message"), + )?, + create_final_assistant_message_sse_response("done second")?, ]; let server = create_mock_chat_completions_server(responses).await; - create_config_toml(&codex_home, &server.uri()).expect("write config"); + create_config_toml(&codex_home, &server.uri())?; - let mut mcp = McpProcess::new(&codex_home) - .await - .expect("spawn mcp process"); - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) - .await - .expect("init timeout") - .expect("init failed"); + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let new_conv_id = mcp .send_new_conversation_request(NewConversationParams { @@ -442,33 +389,29 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() { sandbox: Some(SandboxMode::WorkspaceWrite), ..Default::default() }) - .await - .expect("send newConversation"); + .await?; let new_conv_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)), ) - .await - .expect("newConversation timeout") - .expect("newConversation resp"); + .await??; let NewConversationResponse { conversation_id, model, .. - } = to_response::(new_conv_resp) - .expect("deserialize newConversation response"); + } = to_response::(new_conv_resp)?; let add_listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { conversation_id }) - .await - .expect("send addConversationListener"); + .send_add_conversation_listener_request(AddConversationListenerParams { + conversation_id, + experimental_raw_events: false, + }) + .await?; timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)), ) - .await - .expect("addConversationListener timeout") - .expect("addConversationListener resp"); + .await??; let first_turn_id = mcp .send_send_user_turn_request(SendUserTurnParams { @@ -488,22 +431,17 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() { effort: Some(ReasoningEffort::Medium), summary: ReasoningSummary::Auto, }) - .await - .expect("send first sendUserTurn"); + .await?; timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(first_turn_id)), ) - .await - .expect("sendUserTurn 1 timeout") - .expect("sendUserTurn 1 resp"); + .await??; timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("codex/event/task_complete"), ) - .await - .expect("task_complete 1 timeout") - .expect("task_complete 1 notification"); + .await??; let second_turn_id = mcp .send_send_user_turn_request(SendUserTurnParams { @@ -518,60 +456,18 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() { effort: Some(ReasoningEffort::Medium), summary: ReasoningSummary::Auto, }) - .await - .expect("send second sendUserTurn"); + .await?; timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(second_turn_id)), ) - .await - .expect("sendUserTurn 2 timeout") - .expect("sendUserTurn 2 resp"); - - let mut env_message: Option = None; - let second_cwd_str = second_cwd.to_string_lossy().into_owned(); - for _ in 0..10 { - let notification = timeout( - DEFAULT_READ_TIMEOUT, - mcp.read_stream_until_notification_message("codex/event/user_message"), - ) - .await - .expect("user_message timeout") - .expect("user_message notification"); - let params = notification - .params - .clone() - .expect("user_message should include params"); - let event: Event = serde_json::from_value(params).expect("deserialize user_message event"); - if let EventMsg::UserMessage(user) = event.msg - && matches!(user.kind, Some(InputMessageKind::EnvironmentContext)) - && user.message.contains(&second_cwd_str) - { - env_message = Some(user.message); - break; - } - } - let env_message = env_message.expect("expected environment context update"); - assert!( - env_message.contains("danger-full-access"), - "env context should reflect new sandbox mode: {env_message}" - ); - assert!( - env_message.contains("enabled"), - "env context should enable network access for danger-full-access policy: {env_message}" - ); - assert!( - env_message.contains(&second_cwd_str), - "env context should include updated cwd: {env_message}" - ); + .await??; let exec_begin_notification = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("codex/event/exec_command_begin"), ) - .await - .expect("exec_command_begin timeout") - .expect("exec_command_begin notification"); + .await??; let params = exec_begin_notification .params .clone() @@ -599,9 +495,9 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() { DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("codex/event/task_complete"), ) - .await - .expect("task_complete 2 timeout") - .expect("task_complete 2 notification"); + .await??; + + Ok(()) } fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { diff --git a/codex-rs/app-server/tests/suite/config.rs b/codex-rs/app-server/tests/suite/config.rs index 577eeb388d..227d30bbe3 100644 --- a/codex-rs/app-server/tests/suite/config.rs +++ b/codex-rs/app-server/tests/suite/config.rs @@ -1,6 +1,4 @@ -use std::collections::HashMap; -use std::path::Path; - +use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; use codex_app_server_protocol::GetUserSavedConfigResponse; @@ -11,11 +9,14 @@ use codex_app_server_protocol::SandboxSettings; use codex_app_server_protocol::Tools; use codex_app_server_protocol::UserSavedConfig; use codex_core::protocol::AskForApproval; +use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::Verbosity; use pretty_assertions::assert_eq; +use std::collections::HashMap; +use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; @@ -33,6 +34,8 @@ model_reasoning_summary = "detailed" model_reasoning_effort = "high" model_verbosity = "medium" profile = "test" +forced_chatgpt_workspace_id = "12345678-0000-0000-0000-000000000000" +forced_login_method = "chatgpt" [sandbox_workspace_write] writable_roots = ["/tmp"] @@ -57,31 +60,21 @@ chatgpt_base_url = "https://api.chatgpt.com" } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn get_config_toml_parses_all_fields() { - let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); - create_config_toml(codex_home.path()).expect("write config.toml"); +async fn get_config_toml_parses_all_fields() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; - let mut mcp = McpProcess::new(codex_home.path()) - .await - .expect("spawn mcp process"); - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) - .await - .expect("init timeout") - .expect("init failed"); + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - let request_id = mcp - .send_get_user_saved_config_request() - .await - .expect("send getUserSavedConfig"); + let request_id = mcp.send_get_user_saved_config_request().await?; let resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) - .await - .expect("getUserSavedConfig timeout") - .expect("getUserSavedConfig response"); + .await??; - let config: GetUserSavedConfigResponse = to_response(resp).expect("deserialize config"); + let config: GetUserSavedConfigResponse = to_response(resp)?; let expected = GetUserSavedConfigResponse { config: UserSavedConfig { approval_policy: Some(AskForApproval::OnRequest), @@ -92,6 +85,8 @@ async fn get_config_toml_parses_all_fields() { exclude_tmpdir_env_var: Some(true), exclude_slash_tmp: Some(true), }), + forced_chatgpt_workspace_id: Some("12345678-0000-0000-0000-000000000000".into()), + forced_login_method: Some(ForcedLoginMethod::Chatgpt), model: Some("gpt-5-codex".into()), model_reasoning_effort: Some(ReasoningEffort::High), model_reasoning_summary: Some(ReasoningSummary::Detailed), @@ -117,38 +112,31 @@ async fn get_config_toml_parses_all_fields() { }; assert_eq!(config, expected); + Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn get_config_toml_empty() { - let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); +async fn get_config_toml_empty() -> Result<()> { + let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()) - .await - .expect("spawn mcp process"); - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) - .await - .expect("init timeout") - .expect("init failed"); + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - let request_id = mcp - .send_get_user_saved_config_request() - .await - .expect("send getUserSavedConfig"); + let request_id = mcp.send_get_user_saved_config_request().await?; let resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) - .await - .expect("getUserSavedConfig timeout") - .expect("getUserSavedConfig response"); + .await??; - let config: GetUserSavedConfigResponse = to_response(resp).expect("deserialize config"); + let config: GetUserSavedConfigResponse = to_response(resp)?; let expected = GetUserSavedConfigResponse { config: UserSavedConfig { approval_policy: None, sandbox_mode: None, sandbox_settings: None, + forced_chatgpt_workspace_id: None, + forced_login_method: None, model: None, model_reasoning_effort: None, model_reasoning_summary: None, @@ -160,4 +148,5 @@ async fn get_config_toml_empty() { }; assert_eq!(config, expected); + Ok(()) } diff --git a/codex-rs/app-server/tests/suite/create_conversation.rs b/codex-rs/app-server/tests/suite/create_conversation.rs index 37a0db84fa..7788b8f382 100644 --- a/codex-rs/app-server/tests/suite/create_conversation.rs +++ b/codex-rs/app-server/tests/suite/create_conversation.rs @@ -1,5 +1,4 @@ -use std::path::Path; - +use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_chat_completions_server; @@ -15,31 +14,25 @@ use codex_app_server_protocol::SendUserMessageParams; use codex_app_server_protocol::SendUserMessageResponse; use pretty_assertions::assert_eq; use serde_json::json; +use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_conversation_create_and_send_message_ok() { +async fn test_conversation_create_and_send_message_ok() -> Result<()> { // Mock server – we won't strictly rely on it, but provide one to satisfy any model wiring. - let responses = vec![ - create_final_assistant_message_sse_response("Done").expect("build mock assistant message"), - ]; + let responses = vec![create_final_assistant_message_sse_response("Done")?]; let server = create_mock_chat_completions_server(responses).await; // Temporary Codex home with config pointing at the mock server. - let codex_home = TempDir::new().expect("create temp dir"); - create_config_toml(codex_home.path(), &server.uri()).expect("write config.toml"); + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; // Start MCP server process and initialize. - let mut mcp = McpProcess::new(codex_home.path()) - .await - .expect("spawn mcp process"); - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) - .await - .expect("init timeout") - .expect("init failed"); + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // Create a conversation via the new JSON-RPC API. let new_conv_id = mcp @@ -47,40 +40,35 @@ async fn test_conversation_create_and_send_message_ok() { model: Some("o3".to_string()), ..Default::default() }) - .await - .expect("send newConversation"); + .await?; let new_conv_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)), ) - .await - .expect("newConversation timeout") - .expect("newConversation resp"); + .await??; let NewConversationResponse { conversation_id, model, reasoning_effort: _, rollout_path: _, - } = to_response::(new_conv_resp) - .expect("deserialize newConversation response"); + } = to_response::(new_conv_resp)?; assert_eq!(model, "o3"); // Add a listener so we receive notifications for this conversation (not strictly required for this test). let add_listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { conversation_id }) - .await - .expect("send addConversationListener"); + .send_add_conversation_listener_request(AddConversationListenerParams { + conversation_id, + experimental_raw_events: false, + }) + .await?; let _sub: AddConversationSubscriptionResponse = to_response::( timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)), ) - .await - .expect("addConversationListener timeout") - .expect("addConversationListener resp"), - ) - .expect("deserialize addConversationListener response"); + .await??, + )?; // Now send a user message via the wire API and expect an OK (empty object) result. let send_id = mcp @@ -90,36 +78,32 @@ async fn test_conversation_create_and_send_message_ok() { text: "Hello".to_string(), }], }) - .await - .expect("send sendUserMessage"); + .await?; let send_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(send_id)), ) - .await - .expect("sendUserMessage timeout") - .expect("sendUserMessage resp"); - let _ok: SendUserMessageResponse = to_response::(send_resp) - .expect("deserialize sendUserMessage response"); + .await??; + let _ok: SendUserMessageResponse = to_response::(send_resp)?; // avoid race condition by waiting for the mock server to receive the chat.completions request let deadline = std::time::Instant::now() + DEFAULT_READ_TIMEOUT; - loop { + let requests = loop { let requests = server.received_requests().await.unwrap_or_default(); if !requests.is_empty() { - break; + break requests; } if std::time::Instant::now() >= deadline { panic!("mock server did not receive the chat.completions request in time"); } tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } + }; // Verify the outbound request body matches expectations for Chat Completions. - let request = &server.received_requests().await.unwrap()[0]; - let body = request - .body_json::() - .expect("parse request body as JSON"); + let request = requests + .first() + .expect("mock server should have received at least one request"); + let body = request.body_json::()?; assert_eq!(body["model"], json!("o3")); assert!(body["stream"].as_bool().unwrap_or(false)); let messages = body["messages"] @@ -130,6 +114,7 @@ async fn test_conversation_create_and_send_message_ok() { assert_eq!(last["content"], json!("Hello")); drop(server); + Ok(()) } // Helper to create a config.toml pointing at the mock model server. diff --git a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs index a2bc974ad0..9c95e3de34 100644 --- a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs +++ b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs @@ -1,5 +1,5 @@ -use anyhow::Context; use anyhow::Result; +use anyhow::anyhow; use app_test_support::McpProcess; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -13,48 +13,39 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fuzzy_file_search_sorts_and_includes_indices() -> Result<()> { // Prepare a temporary Codex home and a separate root with test files. - let codex_home = TempDir::new().context("create temp codex home")?; - let root = TempDir::new().context("create temp search root")?; + let codex_home = TempDir::new()?; + let root = TempDir::new()?; // Create files designed to have deterministic ordering for query "abe". - std::fs::write(root.path().join("abc"), "x").context("write file abc")?; - std::fs::write(root.path().join("abcde"), "x").context("write file abcde")?; - std::fs::write(root.path().join("abexy"), "x").context("write file abexy")?; - std::fs::write(root.path().join("zzz.txt"), "x").context("write file zzz")?; + std::fs::write(root.path().join("abc"), "x")?; + std::fs::write(root.path().join("abcde"), "x")?; + std::fs::write(root.path().join("abexy"), "x")?; + std::fs::write(root.path().join("zzz.txt"), "x")?; let sub_dir = root.path().join("sub"); - std::fs::create_dir_all(&sub_dir).context("create sub dir")?; + std::fs::create_dir_all(&sub_dir)?; let sub_abce_path = sub_dir.join("abce"); - std::fs::write(&sub_abce_path, "x").context("write file sub/abce")?; + std::fs::write(&sub_abce_path, "x")?; let sub_abce_rel = sub_abce_path - .strip_prefix(root.path()) - .context("strip root prefix from sub/abce")? + .strip_prefix(root.path())? .to_string_lossy() .to_string(); // Start MCP server and initialize. - let mut mcp = McpProcess::new(codex_home.path()) - .await - .context("spawn mcp")?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) - .await - .context("init timeout")? - .context("init failed")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let root_path = root.path().to_string_lossy().to_string(); // Send fuzzyFileSearch request. let request_id = mcp .send_fuzzy_file_search_request("abe", vec![root_path.clone()], None) - .await - .context("send fuzzyFileSearch")?; + .await?; // Read response and verify shape and ordering. let resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) - .await - .context("fuzzyFileSearch timeout")? - .context("fuzzyFileSearch resp")?; + .await??; let value = resp.result; // The path separator on Windows affects the score. @@ -94,24 +85,18 @@ async fn test_fuzzy_file_search_sorts_and_includes_indices() -> Result<()> { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_fuzzy_file_search_accepts_cancellation_token() -> Result<()> { - let codex_home = TempDir::new().context("create temp codex home")?; - let root = TempDir::new().context("create temp search root")?; + let codex_home = TempDir::new()?; + let root = TempDir::new()?; - std::fs::write(root.path().join("alpha.txt"), "contents").context("write alpha")?; + std::fs::write(root.path().join("alpha.txt"), "contents")?; - let mut mcp = McpProcess::new(codex_home.path()) - .await - .context("spawn mcp")?; - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) - .await - .context("init timeout")? - .context("init failed")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let root_path = root.path().to_string_lossy().to_string(); let request_id = mcp .send_fuzzy_file_search_request("alp", vec![root_path.clone()], None) - .await - .context("send fuzzyFileSearch")?; + .await?; let request_id_2 = mcp .send_fuzzy_file_search_request( @@ -119,23 +104,20 @@ async fn test_fuzzy_file_search_accepts_cancellation_token() -> Result<()> { vec![root_path.clone()], Some(request_id.to_string()), ) - .await - .context("send fuzzyFileSearch")?; + .await?; let resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id_2)), ) - .await - .context("fuzzyFileSearch timeout")? - .context("fuzzyFileSearch resp")?; + .await??; let files = resp .result .get("files") - .context("files key missing")? + .ok_or_else(|| anyhow!("files key missing"))? .as_array() - .context("files not array")? + .ok_or_else(|| anyhow!("files not array"))? .clone(); assert_eq!(files.len(), 1); diff --git a/codex-rs/app-server/tests/suite/interrupt.rs b/codex-rs/app-server/tests/suite/interrupt.rs index 2500d20ff4..86b0a3f3f8 100644 --- a/codex-rs/app-server/tests/suite/interrupt.rs +++ b/codex-rs/app-server/tests/suite/interrupt.rs @@ -88,7 +88,10 @@ async fn shell_command_interruption() -> anyhow::Result<()> { // 2) addConversationListener let add_listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { conversation_id }) + .send_add_conversation_listener_request(AddConversationListenerParams { + conversation_id, + experimental_raw_events: false, + }) .await?; let _add_listener_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, @@ -143,7 +146,7 @@ fn create_config_toml(codex_home: &Path, server_uri: String) -> std::io::Result< r#" model = "mock-model" approval_policy = "never" -sandbox_mode = "danger-full-access" +sandbox_mode = "read-only" model_provider = "mock_provider" diff --git a/codex-rs/app-server/tests/suite/list_resume.rs b/codex-rs/app-server/tests/suite/list_resume.rs index a4178b0873..30be93a2e6 100644 --- a/codex-rs/app-server/tests/suite/list_resume.rs +++ b/codex-rs/app-server/tests/suite/list_resume.rs @@ -1,7 +1,6 @@ -use std::fs; -use std::path::Path; - +use anyhow::Result; use app_test_support::McpProcess; +use app_test_support::create_fake_rollout; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; @@ -13,67 +12,66 @@ use codex_app_server_protocol::ResumeConversationParams; use codex_app_server_protocol::ResumeConversationResponse; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SessionConfiguredNotification; +use codex_core::protocol::EventMsg; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; use pretty_assertions::assert_eq; -use serde_json::json; 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(flavor = "multi_thread", worker_threads = 2)] -async fn test_list_and_resume_conversations() { +async fn test_list_and_resume_conversations() -> Result<()> { // Prepare a temporary CODEX_HOME with a few fake rollout files. - let codex_home = TempDir::new().expect("create temp dir"); + let codex_home = TempDir::new()?; create_fake_rollout( codex_home.path(), "2025-01-02T12-00-00", "2025-01-02T12:00:00Z", "Hello A", - ); + Some("openai"), + )?; create_fake_rollout( codex_home.path(), "2025-01-01T13-00-00", "2025-01-01T13:00:00Z", "Hello B", - ); + Some("openai"), + )?; create_fake_rollout( codex_home.path(), "2025-01-01T12-00-00", "2025-01-01T12:00:00Z", "Hello C", - ); + None, + )?; - let mut mcp = McpProcess::new(codex_home.path()) - .await - .expect("spawn mcp process"); - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) - .await - .expect("init timeout") - .expect("init failed"); + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // Request first page with size 2 let req_id = mcp .send_list_conversations_request(ListConversationsParams { page_size: Some(2), cursor: None, + model_providers: None, }) - .await - .expect("send listConversations"); + .await?; let resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(req_id)), ) - .await - .expect("listConversations timeout") - .expect("listConversations resp"); + .await??; let ListConversationsResponse { items, next_cursor } = - to_response::(resp).expect("deserialize response"); + to_response::(resp)?; assert_eq!(items.len(), 2); // Newest first; preview text should match assert_eq!(items[0].preview, "Hello A"); assert_eq!(items[1].preview, "Hello B"); + assert_eq!(items[0].model_provider, "openai"); + assert_eq!(items[1].model_provider, "openai"); assert!(items[0].path.is_absolute()); assert!(next_cursor.is_some()); @@ -82,129 +80,277 @@ async fn test_list_and_resume_conversations() { .send_list_conversations_request(ListConversationsParams { page_size: Some(2), cursor: next_cursor, + model_providers: None, }) - .await - .expect("send listConversations page 2"); + .await?; let resp2: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(req_id2)), ) - .await - .expect("listConversations page 2 timeout") - .expect("listConversations page 2 resp"); + .await??; let ListConversationsResponse { items: items2, next_cursor: next2, .. - } = to_response::(resp2).expect("deserialize response"); + } = to_response::(resp2)?; assert_eq!(items2.len(), 1); assert_eq!(items2[0].preview, "Hello C"); - assert!(next2.is_some()); + assert_eq!(items2[0].model_provider, "openai"); + assert_eq!(next2, None); - // Now resume one of the sessions and expect a SessionConfigured notification and response. + // Add a conversation with an explicit non-OpenAI provider for filter tests. + create_fake_rollout( + codex_home.path(), + "2025-01-01T11-30-00", + "2025-01-01T11:30:00Z", + "Hello TP", + Some("test-provider"), + )?; + + // Filtering by model provider should return only matching sessions. + let filter_req_id = mcp + .send_list_conversations_request(ListConversationsParams { + page_size: Some(10), + cursor: None, + model_providers: Some(vec!["test-provider".to_string()]), + }) + .await?; + let filter_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(filter_req_id)), + ) + .await??; + let ListConversationsResponse { + items: filtered_items, + next_cursor: filtered_next, + } = to_response::(filter_resp)?; + assert_eq!(filtered_items.len(), 1); + assert_eq!(filtered_next, None); + assert_eq!(filtered_items[0].preview, "Hello TP"); + assert_eq!(filtered_items[0].model_provider, "test-provider"); + + // Empty filter should include every session regardless of provider metadata. + let unfiltered_req_id = mcp + .send_list_conversations_request(ListConversationsParams { + page_size: Some(10), + cursor: None, + model_providers: Some(Vec::new()), + }) + .await?; + let unfiltered_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(unfiltered_req_id)), + ) + .await??; + let ListConversationsResponse { + items: unfiltered_items, + next_cursor: unfiltered_next, + } = to_response::(unfiltered_resp)?; + assert_eq!(unfiltered_items.len(), 4); + assert!(unfiltered_next.is_none()); + + let empty_req_id = mcp + .send_list_conversations_request(ListConversationsParams { + page_size: Some(10), + cursor: None, + model_providers: Some(vec!["other".to_string()]), + }) + .await?; + let empty_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(empty_req_id)), + ) + .await??; + let ListConversationsResponse { + items: empty_items, + next_cursor: empty_next, + } = to_response::(empty_resp)?; + assert!(empty_items.is_empty()); + assert!(empty_next.is_none()); + + let first_item = &items[0]; + + // Now resume one of the sessions from an explicit rollout path. let resume_req_id = mcp .send_resume_conversation_request(ResumeConversationParams { - path: items[0].path.clone(), + path: Some(first_item.path.clone()), + conversation_id: None, + history: None, overrides: Some(NewConversationParams { model: Some("o3".to_string()), ..Default::default() }), }) - .await - .expect("send resumeConversation"); + .await?; // Expect a codex/event notification with msg.type == sessionConfigured let notification: JSONRPCNotification = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("sessionConfigured"), ) - .await - .expect("sessionConfigured notification timeout") - .expect("sessionConfigured notification"); - let session_configured: ServerNotification = notification - .try_into() - .expect("deserialize sessionConfigured notification"); - // Basic shape assertion: ensure event type is sessionConfigured + .await??; + let session_configured: ServerNotification = notification.try_into()?; let ServerNotification::SessionConfigured(SessionConfiguredNotification { model, rollout_path, + initial_messages: session_initial_messages, .. }) = session_configured else { unreachable!("expected sessionConfigured notification"); }; assert_eq!(model, "o3"); - assert_eq!(items[0].path.clone(), rollout_path); + assert_eq!(rollout_path, first_item.path.clone()); + let session_initial_messages = session_initial_messages + .expect("expected initial messages when resuming from rollout path"); + match session_initial_messages.as_slice() { + [EventMsg::UserMessage(message)] => { + assert_eq!(message.message, first_item.preview.clone()); + } + other => panic!("unexpected initial messages from rollout resume: {other:#?}"), + } // Then the response for resumeConversation let resume_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(resume_req_id)), ) - .await - .expect("resumeConversation timeout") - .expect("resumeConversation resp"); + .await??; let ResumeConversationResponse { - conversation_id, .. - } = to_response::(resume_resp) - .expect("deserialize resumeConversation response"); + conversation_id, + model: resume_model, + initial_messages: response_initial_messages, + .. + } = to_response::(resume_resp)?; // conversation id should be a valid UUID assert!(!conversation_id.to_string().is_empty()); -} + assert_eq!(resume_model, "o3"); + let response_initial_messages = + response_initial_messages.expect("expected initial messages in resume response"); + match response_initial_messages.as_slice() { + [EventMsg::UserMessage(message)] => { + assert_eq!(message.message, first_item.preview.clone()); + } + other => panic!("unexpected initial messages in resume response: {other:#?}"), + } -fn create_fake_rollout(codex_home: &Path, filename_ts: &str, meta_rfc3339: &str, preview: &str) { - let uuid = Uuid::new_v4(); - // sessions/YYYY/MM/DD/ derived from filename_ts (YYYY-MM-DDThh-mm-ss) - let year = &filename_ts[0..4]; - let month = &filename_ts[5..7]; - let day = &filename_ts[8..10]; - let dir = codex_home.join("sessions").join(year).join(month).join(day); - fs::create_dir_all(&dir).unwrap_or_else(|e| panic!("create sessions dir: {e}")); + // Resuming with only a conversation id should locate the rollout automatically. + let resume_by_id_req_id = mcp + .send_resume_conversation_request(ResumeConversationParams { + path: None, + conversation_id: Some(first_item.conversation_id), + history: None, + overrides: Some(NewConversationParams { + model: Some("o3".to_string()), + ..Default::default() + }), + }) + .await?; + let notification: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("sessionConfigured"), + ) + .await??; + let session_configured: ServerNotification = notification.try_into()?; + let ServerNotification::SessionConfigured(SessionConfiguredNotification { + model, + rollout_path, + initial_messages: session_initial_messages, + .. + }) = session_configured + else { + unreachable!("expected sessionConfigured notification"); + }; + assert_eq!(model, "o3"); + assert_eq!(rollout_path, first_item.path.clone()); + let session_initial_messages = session_initial_messages + .expect("expected initial messages when resuming from conversation id"); + match session_initial_messages.as_slice() { + [EventMsg::UserMessage(message)] => { + assert_eq!(message.message, first_item.preview.clone()); + } + other => panic!("unexpected initial messages from conversation id resume: {other:#?}"), + } + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_by_id_req_id)), + ) + .await??; + let ResumeConversationResponse { + conversation_id: by_id_conversation_id, + model: by_id_model, + initial_messages: by_id_initial_messages, + .. + } = to_response::(resume_resp)?; + assert!(!by_id_conversation_id.to_string().is_empty()); + assert_eq!(by_id_model, "o3"); + let by_id_initial_messages = by_id_initial_messages + .expect("expected initial messages when resuming from conversation id response"); + match by_id_initial_messages.as_slice() { + [EventMsg::UserMessage(message)] => { + assert_eq!(message.message, first_item.preview.clone()); + } + other => { + panic!("unexpected initial messages in conversation id resume response: {other:#?}") + } + } - let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl")); - let mut lines = Vec::new(); - // Meta line with timestamp (flattened meta in payload for new schema) - lines.push( - json!({ - "timestamp": meta_rfc3339, - "type": "session_meta", - "payload": { - "id": uuid, - "timestamp": meta_rfc3339, - "cwd": "/", - "originator": "codex", - "cli_version": "0.0.0", - "instructions": null - } + // Resuming with explicit history should succeed even without a stored rollout. + let fork_history_text = "Hello from history"; + let history = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: fork_history_text.to_string(), + }], + }]; + let resume_with_history_req_id = mcp + .send_resume_conversation_request(ResumeConversationParams { + path: None, + conversation_id: None, + history: Some(history), + overrides: Some(NewConversationParams { + model: Some("o3".to_string()), + ..Default::default() + }), }) - .to_string(), + .await?; + let notification: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("sessionConfigured"), + ) + .await??; + let session_configured: ServerNotification = notification.try_into()?; + let ServerNotification::SessionConfigured(SessionConfiguredNotification { + model, + initial_messages: session_initial_messages, + .. + }) = session_configured + else { + unreachable!("expected sessionConfigured notification"); + }; + assert_eq!(model, "o3"); + assert!( + session_initial_messages.as_ref().is_none_or(Vec::is_empty), + "expected no initial messages when resuming from explicit history but got {session_initial_messages:#?}" ); - // Minimal user message entry as a persisted response item (with envelope timestamp) - lines.push( - json!({ - "timestamp": meta_rfc3339, - "type":"response_item", - "payload": { - "type":"message", - "role":"user", - "content":[{"type":"input_text","text": preview}] - } - }) - .to_string(), + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_with_history_req_id)), + ) + .await??; + let ResumeConversationResponse { + conversation_id: history_conversation_id, + model: history_model, + initial_messages: history_initial_messages, + .. + } = to_response::(resume_resp)?; + assert!(!history_conversation_id.to_string().is_empty()); + assert_eq!(history_model, "o3"); + assert!( + history_initial_messages.as_ref().is_none_or(Vec::is_empty), + "expected no initial messages in resume response when history is provided but got {history_initial_messages:#?}" ); - // Add a matching user message event line to satisfy filters - lines.push( - json!({ - "timestamp": meta_rfc3339, - "type":"event_msg", - "payload": { - "type":"user_message", - "message": preview, - "kind": "plain" - } - }) - .to_string(), - ); - fs::write(file_path, lines.join("\n") + "\n") - .unwrap_or_else(|e| panic!("write rollout file: {e}")); + + Ok(()) } diff --git a/codex-rs/app-server/tests/suite/login.rs b/codex-rs/app-server/tests/suite/login.rs index 6dcbde1125..c5470c3ec4 100644 --- a/codex-rs/app-server/tests/suite/login.rs +++ b/codex-rs/app-server/tests/suite/login.rs @@ -1,17 +1,20 @@ -use std::path::Path; -use std::time::Duration; - +use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; use codex_app_server_protocol::CancelLoginChatGptParams; use codex_app_server_protocol::CancelLoginChatGptResponse; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; +use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginChatGptResponse; use codex_app_server_protocol::LogoutChatGptResponse; use codex_app_server_protocol::RequestId; +use codex_core::auth::AuthCredentialsStoreMode; use codex_login::login_with_api_key; +use serial_test::serial; +use std::path::Path; +use std::time::Duration; use tempfile::TempDir; use tokio::time::timeout; @@ -40,32 +43,26 @@ stream_max_retries = 0 } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn logout_chatgpt_removes_auth() { - let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); - create_config_toml(codex_home.path()).expect("write config.toml"); - login_with_api_key(codex_home.path(), "sk-test-key").expect("seed api key"); +async fn logout_chatgpt_removes_auth() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + login_with_api_key( + codex_home.path(), + "sk-test-key", + AuthCredentialsStoreMode::File, + )?; assert!(codex_home.path().join("auth.json").exists()); - let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]) - .await - .expect("spawn mcp process"); - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) - .await - .expect("init timeout") - .expect("init failed"); + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - let id = mcp - .send_logout_chat_gpt_request() - .await - .expect("send logoutChatGpt"); + let id = mcp.send_logout_chat_gpt_request().await?; let resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(id)), ) - .await - .expect("logoutChatGpt timeout") - .expect("logoutChatGpt response"); - let _ok: LogoutChatGptResponse = to_response(resp).expect("deserialize logout response"); + .await??; + let _ok: LogoutChatGptResponse = to_response(resp)?; assert!( !codex_home.path().join("auth.json").exists(), @@ -78,61 +75,47 @@ async fn logout_chatgpt_removes_auth() { include_token: Some(true), refresh_token: Some(false), }) - .await - .expect("send getAuthStatus"); + .await?; let status_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(status_id)), ) - .await - .expect("getAuthStatus timeout") - .expect("getAuthStatus response"); - let status: GetAuthStatusResponse = to_response(status_resp).expect("deserialize status"); + .await??; + let status: GetAuthStatusResponse = to_response(status_resp)?; assert_eq!(status.auth_method, None); assert_eq!(status.auth_token, None); + Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn login_and_cancel_chatgpt() { - let codex_home = TempDir::new().unwrap_or_else(|e| panic!("create tempdir: {e}")); - create_config_toml(codex_home.path()).unwrap_or_else(|err| panic!("write config.toml: {err}")); +// Serialize tests that launch the login server since it binds to a fixed port. +#[serial(login_port)] +async fn login_and_cancel_chatgpt() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; - let mut mcp = McpProcess::new(codex_home.path()) - .await - .expect("spawn mcp process"); - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) - .await - .expect("init timeout") - .expect("init failed"); + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - let login_id = mcp - .send_login_chat_gpt_request() - .await - .expect("send loginChatGpt"); + let login_id = mcp.send_login_chat_gpt_request().await?; let login_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(login_id)), ) - .await - .expect("loginChatGpt timeout") - .expect("loginChatGpt response"); - let login: LoginChatGptResponse = to_response(login_resp).expect("deserialize login resp"); + .await??; + let login: LoginChatGptResponse = to_response(login_resp)?; let cancel_id = mcp .send_cancel_login_chat_gpt_request(CancelLoginChatGptParams { login_id: login.login_id, }) - .await - .expect("send cancelLoginChatGpt"); + .await?; let cancel_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(cancel_id)), ) - .await - .expect("cancelLoginChatGpt timeout") - .expect("cancelLoginChatGpt response"); - let _ok: CancelLoginChatGptResponse = - to_response(cancel_resp).expect("deserialize cancel response"); + .await??; + let _ok: CancelLoginChatGptResponse = to_response(cancel_resp)?; // Optionally observe the completion notification; do not fail if it races. let maybe_note = timeout( @@ -143,4 +126,81 @@ async fn login_and_cancel_chatgpt() { if maybe_note.is_err() { eprintln!("warning: did not observe login_chat_gpt_complete notification after cancel"); } + Ok(()) +} + +fn create_config_toml_forced_login(codex_home: &Path, forced_method: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + let contents = format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "danger-full-access" +forced_login_method = "{forced_method}" +"# + ); + std::fs::write(config_toml, contents) +} + +fn create_config_toml_forced_workspace( + codex_home: &Path, + workspace_id: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + let contents = format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "danger-full-access" +forced_chatgpt_workspace_id = "{workspace_id}" +"# + ); + std::fs::write(config_toml, contents) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn login_chatgpt_rejected_when_forced_api() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml_forced_login(codex_home.path(), "api")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_chat_gpt_request().await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!( + err.error.message, + "ChatGPT login is disabled. Use API key login instead." + ); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +// Serialize tests that launch the login server since it binds to a fixed port. +#[serial(login_port)] +async fn login_chatgpt_includes_forced_workspace_query_param() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml_forced_workspace(codex_home.path(), "ws-forced")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_chat_gpt_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let login: LoginChatGptResponse = to_response(resp)?; + assert!( + login.auth_url.contains("allowed_workspace_id=ws-forced"), + "auth URL should include forced workspace" + ); + Ok(()) } diff --git a/codex-rs/app-server/tests/suite/mod.rs b/codex-rs/app-server/tests/suite/mod.rs index 78ce310e74..37f7659f42 100644 --- a/codex-rs/app-server/tests/suite/mod.rs +++ b/codex-rs/app-server/tests/suite/mod.rs @@ -11,3 +11,4 @@ mod send_message; mod set_default_model; mod user_agent; mod user_info; +mod v2; diff --git a/codex-rs/app-server/tests/suite/send_message.rs b/codex-rs/app-server/tests/suite/send_message.rs index 22fb02dca9..8d2b36af2f 100644 --- a/codex-rs/app-server/tests/suite/send_message.rs +++ b/codex-rs/app-server/tests/suite/send_message.rs @@ -1,5 +1,4 @@ -use std::path::Path; - +use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_chat_completions_server; @@ -15,73 +14,76 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SendUserMessageParams; use codex_app_server_protocol::SendUserMessageResponse; use codex_protocol::ConversationId; +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::RawResponseItemEvent; use pretty_assertions::assert_eq; +use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] -async fn test_send_message_success() { +async fn test_send_message_success() -> Result<()> { // Spin up a mock completions server that immediately ends the Codex turn. // Two Codex turns hit the mock model (session start + send-user-message). Provide two SSE responses. let responses = vec![ - create_final_assistant_message_sse_response("Done").expect("build mock assistant message"), - create_final_assistant_message_sse_response("Done").expect("build mock assistant message"), + create_final_assistant_message_sse_response("Done")?, + create_final_assistant_message_sse_response("Done")?, ]; let server = create_mock_chat_completions_server(responses).await; // Create a temporary Codex home with config pointing at the mock server. - let codex_home = TempDir::new().expect("create temp dir"); - create_config_toml(codex_home.path(), &server.uri()).expect("write config.toml"); + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; // Start MCP server process and initialize. - let mut mcp = McpProcess::new(codex_home.path()) - .await - .expect("spawn mcp process"); - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) - .await - .expect("init timed out") - .expect("init failed"); + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; // Start a conversation using the new wire API. let new_conv_id = mcp - .send_new_conversation_request(NewConversationParams::default()) - .await - .expect("send newConversation"); + .send_new_conversation_request(NewConversationParams { + ..Default::default() + }) + .await?; let new_conv_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)), ) - .await - .expect("newConversation timeout") - .expect("newConversation resp"); + .await??; let NewConversationResponse { conversation_id, .. - } = to_response::<_>(new_conv_resp).expect("deserialize newConversation response"); + } = to_response::<_>(new_conv_resp)?; // 2) addConversationListener let add_listener_id = mcp - .send_add_conversation_listener_request(AddConversationListenerParams { conversation_id }) - .await - .expect("send addConversationListener"); + .send_add_conversation_listener_request(AddConversationListenerParams { + conversation_id, + experimental_raw_events: false, + }) + .await?; let add_listener_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)), ) - .await - .expect("addConversationListener timeout") - .expect("addConversationListener resp"); + .await??; let AddConversationSubscriptionResponse { subscription_id: _ } = - to_response::<_>(add_listener_resp).expect("deserialize addConversationListener response"); + to_response::<_>(add_listener_resp)?; // Now exercise sendUserMessage twice. - send_message("Hello", conversation_id, &mut mcp).await; - send_message("Hello again", conversation_id, &mut mcp).await; + send_message("Hello", conversation_id, &mut mcp).await?; + send_message("Hello again", conversation_id, &mut mcp).await?; + Ok(()) } #[expect(clippy::expect_used)] -async fn send_message(message: &str, conversation_id: ConversationId, mcp: &mut McpProcess) { +async fn send_message( + message: &str, + conversation_id: ConversationId, + mcp: &mut McpProcess, +) -> Result<()> { // Now exercise sendUserMessage. let send_id = mcp .send_send_user_message_request(SendUserMessageParams { @@ -90,19 +92,15 @@ async fn send_message(message: &str, conversation_id: ConversationId, mcp: &mut text: message.to_string(), }], }) - .await - .expect("send sendUserMessage"); + .await?; let response: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(send_id)), ) - .await - .expect("sendUserMessage response timeout") - .expect("sendUserMessage response error"); + .await??; - let _ok: SendUserMessageResponse = to_response::(response) - .expect("deserialize sendUserMessage response"); + let _ok: SendUserMessageResponse = to_response::(response)?; // Verify the task_finished notification is received. // Note this also ensures that the final request to the server was made. @@ -110,9 +108,7 @@ async fn send_message(message: &str, conversation_id: ConversationId, mcp: &mut DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("codex/event/task_complete"), ) - .await - .expect("task_finished_notification timeout") - .expect("task_finished_notification resp"); + .await??; let serde_json::Value::Object(map) = task_finished_notification .params .expect("notification should have params") @@ -124,17 +120,105 @@ async fn send_message(message: &str, conversation_id: ConversationId, mcp: &mut .expect("should have conversationId"), &serde_json::Value::String(conversation_id.to_string()) ); + + let raw_attempt = tokio::time::timeout( + std::time::Duration::from_millis(200), + mcp.read_stream_until_notification_message("codex/event/raw_response_item"), + ) + .await; + assert!( + raw_attempt.is_err(), + "unexpected raw item notification when not opted in" + ); + Ok(()) } #[tokio::test] -async fn test_send_message_session_not_found() { +async fn test_send_message_raw_notifications_opt_in() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_chat_completions_server(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let new_conv_id = mcp + .send_new_conversation_request(NewConversationParams { + developer_instructions: Some("Use the test harness tools.".to_string()), + ..Default::default() + }) + .await?; + let new_conv_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(new_conv_id)), + ) + .await??; + let NewConversationResponse { + conversation_id, .. + } = to_response::<_>(new_conv_resp)?; + + let add_listener_id = mcp + .send_add_conversation_listener_request(AddConversationListenerParams { + conversation_id, + experimental_raw_events: true, + }) + .await?; + let add_listener_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)), + ) + .await??; + let AddConversationSubscriptionResponse { subscription_id: _ } = + to_response::<_>(add_listener_resp)?; + + let send_id = mcp + .send_send_user_message_request(SendUserMessageParams { + conversation_id, + items: vec![InputItem::Text { + text: "Hello".to_string(), + }], + }) + .await?; + + let developer = read_raw_response_item(&mut mcp, conversation_id).await; + assert_developer_message(&developer, "Use the test harness tools."); + + let instructions = read_raw_response_item(&mut mcp, conversation_id).await; + assert_instructions_message(&instructions); + + let environment = read_raw_response_item(&mut mcp, conversation_id).await; + assert_environment_message(&environment); + + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(send_id)), + ) + .await??; + let _ok: SendUserMessageResponse = to_response::(response)?; + + let user_message = read_raw_response_item(&mut mcp, conversation_id).await; + assert_user_message(&user_message, "Hello"); + + let assistant_message = read_raw_response_item(&mut mcp, conversation_id).await; + assert_assistant_message(&assistant_message, "Done"); + + let _ = tokio::time::timeout( + std::time::Duration::from_millis(250), + mcp.read_stream_until_notification_message("codex/event/task_complete"), + ) + .await; + + Ok(()) +} + +#[tokio::test] +async fn test_send_message_session_not_found() -> Result<()> { // Start MCP without creating a Codex session - let codex_home = TempDir::new().expect("tempdir"); - let mut mcp = McpProcess::new(codex_home.path()).await.expect("spawn"); - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) - .await - .expect("timeout") - .expect("init"); + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let unknown = ConversationId::new(); let req_id = mcp @@ -144,18 +228,16 @@ async fn test_send_message_session_not_found() { text: "ping".to_string(), }], }) - .await - .expect("send sendUserMessage"); + .await?; // Expect an error response for unknown conversation. let err = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(req_id)), ) - .await - .expect("timeout") - .expect("error"); + .await??; assert_eq!(err.id, RequestId::Integer(req_id)); + Ok(()) } // --------------------------------------------------------------------------- @@ -184,3 +266,126 @@ stream_max_retries = 0 ), ) } + +#[expect(clippy::expect_used)] +async fn read_raw_response_item( + mcp: &mut McpProcess, + conversation_id: ConversationId, +) -> ResponseItem { + let raw_notification: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/raw_response_item"), + ) + .await + .expect("codex/event/raw_response_item notification timeout") + .expect("codex/event/raw_response_item notification resp"); + + let serde_json::Value::Object(params) = raw_notification + .params + .expect("codex/event/raw_response_item should have params") + else { + panic!("codex/event/raw_response_item should have params"); + }; + + let conversation_id_value = params + .get("conversationId") + .and_then(|value| value.as_str()) + .expect("raw response item should include conversationId"); + + assert_eq!( + conversation_id_value, + conversation_id.to_string(), + "raw response item conversation mismatch" + ); + + let msg_value = params + .get("msg") + .cloned() + .expect("raw response item should include msg payload"); + + let event: RawResponseItemEvent = + serde_json::from_value(msg_value).expect("deserialize raw response item"); + event.item +} + +fn assert_instructions_message(item: &ResponseItem) { + match item { + ResponseItem::Message { role, content, .. } => { + assert_eq!(role, "user"); + let texts = content_texts(content); + let is_instructions = texts + .iter() + .any(|text| text.starts_with("# AGENTS.md instructions for ")); + assert!( + is_instructions, + "expected instructions message, got {texts:?}" + ); + } + other => panic!("expected instructions message, got {other:?}"), + } +} + +fn assert_developer_message(item: &ResponseItem, expected_text: &str) { + match item { + ResponseItem::Message { role, content, .. } => { + assert_eq!(role, "developer"); + let texts = content_texts(content); + assert_eq!( + texts, + vec![expected_text], + "expected developer instructions message, got {texts:?}" + ); + } + other => panic!("expected developer instructions message, got {other:?}"), + } +} + +fn assert_environment_message(item: &ResponseItem) { + match item { + ResponseItem::Message { role, content, .. } => { + assert_eq!(role, "user"); + let texts = content_texts(content); + assert!( + texts + .iter() + .any(|text| text.contains("")), + "expected environment context message, got {texts:?}" + ); + } + other => panic!("expected environment message, got {other:?}"), + } +} + +fn assert_user_message(item: &ResponseItem, expected_text: &str) { + match item { + ResponseItem::Message { role, content, .. } => { + assert_eq!(role, "user"); + let texts = content_texts(content); + assert_eq!(texts, vec![expected_text]); + } + other => panic!("expected user message, got {other:?}"), + } +} + +fn assert_assistant_message(item: &ResponseItem, expected_text: &str) { + match item { + ResponseItem::Message { role, content, .. } => { + assert_eq!(role, "assistant"); + let texts = content_texts(content); + assert_eq!(texts, vec![expected_text]); + } + other => panic!("expected assistant message, got {other:?}"), + } +} + +fn content_texts(content: &[ContentItem]) -> Vec<&str> { + content + .iter() + .filter_map(|item| match item { + ContentItem::InputText { text } | ContentItem::OutputText { text } => { + Some(text.as_str()) + } + _ => None, + }) + .collect() +} diff --git a/codex-rs/app-server/tests/suite/set_default_model.rs b/codex-rs/app-server/tests/suite/set_default_model.rs index 6769faa9dc..0c2aa22999 100644 --- a/codex-rs/app-server/tests/suite/set_default_model.rs +++ b/codex-rs/app-server/tests/suite/set_default_model.rs @@ -1,5 +1,4 @@ -use std::path::Path; - +use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCResponse; @@ -8,50 +7,38 @@ use codex_app_server_protocol::SetDefaultModelParams; use codex_app_server_protocol::SetDefaultModelResponse; use codex_core::config::ConfigToml; use pretty_assertions::assert_eq; +use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn set_default_model_persists_overrides() { - let codex_home = TempDir::new().expect("create tempdir"); - create_config_toml(codex_home.path()).expect("write config.toml"); +async fn set_default_model_persists_overrides() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; - let mut mcp = McpProcess::new(codex_home.path()) - .await - .expect("spawn mcp process"); - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) - .await - .expect("init timeout") - .expect("init failed"); + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let params = SetDefaultModelParams { model: Some("gpt-4.1".to_string()), reasoning_effort: None, }; - let request_id = mcp - .send_set_default_model_request(params) - .await - .expect("send setDefaultModel"); + let request_id = mcp.send_set_default_model_request(params).await?; let resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) - .await - .expect("setDefaultModel timeout") - .expect("setDefaultModel response"); + .await??; - let _: SetDefaultModelResponse = - to_response(resp).expect("deserialize setDefaultModel response"); + let _: SetDefaultModelResponse = to_response(resp)?; let config_path = codex_home.path().join("config.toml"); - let config_contents = tokio::fs::read_to_string(&config_path) - .await - .expect("read config.toml"); - let config_toml: ConfigToml = toml::from_str(&config_contents).expect("parse config.toml"); + let config_contents = tokio::fs::read_to_string(&config_path).await?; + let config_toml: ConfigToml = toml::from_str(&config_contents)?; assert_eq!( ConfigToml { @@ -61,6 +48,7 @@ async fn set_default_model_persists_overrides() { }, config_toml, ); + Ok(()) } // Helper to create a config.toml; mirrors create_conversation.rs diff --git a/codex-rs/app-server/tests/suite/user_agent.rs b/codex-rs/app-server/tests/suite/user_agent.rs index 95a0b1a3e0..52ba6e56aa 100644 --- a/codex-rs/app-server/tests/suite/user_agent.rs +++ b/codex-rs/app-server/tests/suite/user_agent.rs @@ -1,3 +1,4 @@ +use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; use codex_app_server_protocol::GetUserAgentResponse; @@ -10,28 +11,18 @@ use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn get_user_agent_returns_current_codex_user_agent() { - let codex_home = TempDir::new().unwrap_or_else(|err| panic!("create tempdir: {err}")); +async fn get_user_agent_returns_current_codex_user_agent() -> Result<()> { + let codex_home = TempDir::new()?; - let mut mcp = McpProcess::new(codex_home.path()) - .await - .expect("spawn mcp process"); - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) - .await - .expect("initialize timeout") - .expect("initialize request"); + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - let request_id = mcp - .send_get_user_agent_request() - .await - .expect("send getUserAgent"); + let request_id = mcp.send_get_user_agent_request().await?; let response: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) - .await - .expect("getUserAgent timeout") - .expect("getUserAgent response"); + .await??; let os_info = os_info::get(); let user_agent = format!( @@ -42,9 +33,9 @@ async fn get_user_agent_returns_current_codex_user_agent() { codex_core::terminal::user_agent() ); - let received: GetUserAgentResponse = - to_response(response).expect("deserialize getUserAgent response"); + let received: GetUserAgentResponse = to_response(response)?; let expected = GetUserAgentResponse { user_agent }; assert_eq!(received, expected); + Ok(()) } diff --git a/codex-rs/app-server/tests/suite/user_info.rs b/codex-rs/app-server/tests/suite/user_info.rs index edd041e553..6a44f1a3ab 100644 --- a/codex-rs/app-server/tests/suite/user_info.rs +++ b/codex-rs/app-server/tests/suite/user_info.rs @@ -1,78 +1,46 @@ -use std::time::Duration; - -use anyhow::Context; +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::to_response; -use base64::Engine; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::UserInfoResponse; -use codex_core::auth::AuthDotJson; -use codex_core::auth::get_auth_file; -use codex_core::auth::write_auth_json; -use codex_core::token_data::IdTokenInfo; -use codex_core::token_data::TokenData; +use codex_core::auth::AuthCredentialsStoreMode; use pretty_assertions::assert_eq; -use serde_json::json; +use std::time::Duration; use tempfile::TempDir; use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn user_info_returns_email_from_auth_json() { - let codex_home = TempDir::new().expect("create tempdir"); +async fn user_info_returns_email_from_auth_json() -> Result<()> { + let codex_home = TempDir::new()?; - let auth_path = get_auth_file(codex_home.path()); - let mut id_token = IdTokenInfo::default(); - id_token.email = Some("user@example.com".to_string()); - id_token.raw_jwt = encode_id_token_with_email("user@example.com").expect("encode id token"); + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access") + .refresh_token("refresh") + .email("user@example.com"), + AuthCredentialsStoreMode::File, + )?; - let auth = AuthDotJson { - openai_api_key: None, - tokens: Some(TokenData { - id_token, - access_token: "access".to_string(), - refresh_token: "refresh".to_string(), - account_id: None, - }), - last_refresh: None, - }; - write_auth_json(&auth_path, &auth).expect("write auth.json"); + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; - let mut mcp = McpProcess::new(codex_home.path()) - .await - .expect("spawn mcp process"); - timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) - .await - .expect("initialize timeout") - .expect("initialize request"); - - let request_id = mcp.send_user_info_request().await.expect("send userInfo"); + let request_id = mcp.send_user_info_request().await?; let response: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) - .await - .expect("userInfo timeout") - .expect("userInfo response"); + .await??; - let received: UserInfoResponse = to_response(response).expect("deserialize userInfo response"); + let received: UserInfoResponse = to_response(response)?; let expected = UserInfoResponse { alleged_user_email: Some("user@example.com".to_string()), }; assert_eq!(received, expected); -} - -fn encode_id_token_with_email(email: &str) -> anyhow::Result { - let header_b64 = URL_SAFE_NO_PAD.encode( - serde_json::to_vec(&json!({ "alg": "none", "typ": "JWT" })) - .context("serialize jwt header")?, - ); - let payload = - serde_json::to_vec(&json!({ "email": email })).context("serialize jwt payload")?; - let payload_b64 = URL_SAFE_NO_PAD.encode(payload); - Ok(format!("{header_b64}.{payload_b64}.signature")) + Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs new file mode 100644 index 0000000000..dd59270739 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -0,0 +1,492 @@ +use anyhow::Result; +use anyhow::bail; +use app_test_support::McpProcess; +use app_test_support::to_response; + +use app_test_support::ChatGptAuthFixture; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::Account; +use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::CancelLoginAccountParams; +use codex_app_server_protocol::CancelLoginAccountResponse; +use codex_app_server_protocol::GetAccountParams; +use codex_app_server_protocol::GetAccountResponse; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::LoginAccountResponse; +use codex_app_server_protocol::LogoutAccountResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerNotification; +use codex_core::auth::AuthCredentialsStoreMode; +use codex_login::login_with_api_key; +use codex_protocol::account::PlanType as AccountPlanType; +use pretty_assertions::assert_eq; +use serial_test::serial; +use std::path::Path; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +// Helper to create a minimal config.toml for the app server +#[derive(Default)] +struct CreateConfigTomlParams { + forced_method: Option, + forced_workspace_id: Option, + requires_openai_auth: Option, +} + +fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + let forced_line = if let Some(method) = params.forced_method { + format!("forced_login_method = \"{method}\"\n") + } else { + String::new() + }; + let forced_workspace_line = if let Some(ws) = params.forced_workspace_id { + format!("forced_chatgpt_workspace_id = \"{ws}\"\n") + } else { + String::new() + }; + let requires_line = match params.requires_openai_auth { + Some(true) => "requires_openai_auth = true\n".to_string(), + Some(false) => String::new(), + None => String::new(), + }; + let contents = format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "danger-full-access" +{forced_line} +{forced_workspace_line} + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "http://127.0.0.1:0/v1" +wire_api = "chat" +request_max_retries = 0 +stream_max_retries = 0 +{requires_line} +"# + ); + std::fs::write(config_toml, contents) +} + +#[tokio::test] +async fn logout_account_removes_auth_and_notifies() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; + + login_with_api_key( + codex_home.path(), + "sk-test-key", + AuthCredentialsStoreMode::File, + )?; + assert!(codex_home.path().join("auth.json").exists()); + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let id = mcp.send_logout_account_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(id)), + ) + .await??; + let _ok: LogoutAccountResponse = to_response(resp)?; + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountUpdated(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + assert!( + payload.auth_mode.is_none(), + "auth_method should be None after logout" + ); + + assert!( + !codex_home.path().join("auth.json").exists(), + "auth.json should be deleted" + ); + + let get_id = mcp + .send_get_account_request(GetAccountParams { + refresh_token: false, + }) + .await?; + let get_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(get_id)), + ) + .await??; + let account: GetAccountResponse = to_response(get_resp)?; + assert_eq!(account.account, None); + Ok(()) +} + +#[tokio::test] +async fn login_account_api_key_succeeds_and_notifies() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_login_account_api_key_request("sk-test-key") + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let login: LoginAccountResponse = to_response(resp)?; + assert_eq!(login, LoginAccountResponse::ApiKey {}); + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/login/completed"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountLoginCompleted(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + pretty_assertions::assert_eq!(payload.login_id, None); + pretty_assertions::assert_eq!(payload.success, true); + pretty_assertions::assert_eq!(payload.error, None); + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/updated"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountUpdated(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + pretty_assertions::assert_eq!(payload.auth_mode, Some(AuthMode::ApiKey)); + + assert!(codex_home.path().join("auth.json").exists()); + Ok(()) +} + +#[tokio::test] +async fn login_account_api_key_rejected_when_forced_chatgpt() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + forced_method: Some("chatgpt".to_string()), + ..Default::default() + }, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_login_account_api_key_request("sk-test-key") + .await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!( + err.error.message, + "API key login is disabled. Use ChatGPT login instead." + ); + Ok(()) +} + +#[tokio::test] +async fn login_account_chatgpt_rejected_when_forced_api() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + forced_method: Some("api".to_string()), + ..Default::default() + }, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_account_chatgpt_request().await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!( + err.error.message, + "ChatGPT login is disabled. Use API key login instead." + ); + Ok(()) +} + +#[tokio::test] +// Serialize tests that launch the login server since it binds to a fixed port. +#[serial(login_port)] +async fn login_account_chatgpt_start() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), CreateConfigTomlParams::default())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_account_chatgpt_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let login: LoginAccountResponse = to_response(resp)?; + let LoginAccountResponse::Chatgpt { login_id, auth_url } = login else { + bail!("unexpected login response: {login:?}"); + }; + assert!( + auth_url.contains("redirect_uri=http%3A%2F%2Flocalhost"), + "auth_url should contain a redirect_uri to localhost" + ); + + let cancel_id = mcp + .send_cancel_login_account_request(CancelLoginAccountParams { + login_id: login_id.clone(), + }) + .await?; + let cancel_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(cancel_id)), + ) + .await??; + let _ok: CancelLoginAccountResponse = to_response(cancel_resp)?; + + let note = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("account/login/completed"), + ) + .await??; + let parsed: ServerNotification = note.try_into()?; + let ServerNotification::AccountLoginCompleted(payload) = parsed else { + bail!("unexpected notification: {parsed:?}"); + }; + pretty_assertions::assert_eq!(payload.login_id, Some(login_id)); + pretty_assertions::assert_eq!(payload.success, false); + assert!( + payload.error.is_some(), + "expected a non-empty error on cancel" + ); + + let maybe_updated = timeout( + Duration::from_millis(500), + mcp.read_stream_until_notification_message("account/updated"), + ) + .await; + assert!( + maybe_updated.is_err(), + "account/updated should not be emitted when login is cancelled" + ); + Ok(()) +} + +#[tokio::test] +// Serialize tests that launch the login server since it binds to a fixed port. +#[serial(login_port)] +async fn login_account_chatgpt_includes_forced_workspace_query_param() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + forced_workspace_id: Some("ws-forced".to_string()), + ..Default::default() + }, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_login_account_chatgpt_request().await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let login: LoginAccountResponse = to_response(resp)?; + let LoginAccountResponse::Chatgpt { auth_url, .. } = login else { + bail!("unexpected login response: {login:?}"); + }; + assert!( + auth_url.contains("allowed_workspace_id=ws-forced"), + "auth URL should include forced workspace" + ); + Ok(()) +} + +#[tokio::test] +async fn get_account_no_auth() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + ..Default::default() + }, + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let params = GetAccountParams { + refresh_token: false, + }; + let request_id = mcp.send_get_account_request(params).await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let account: GetAccountResponse = to_response(resp)?; + + assert_eq!(account.account, None, "expected no account"); + assert_eq!(account.requires_openai_auth, true); + Ok(()) +} + +#[tokio::test] +async fn get_account_with_api_key() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + ..Default::default() + }, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_login_account_api_key_request("sk-test-key") + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let _login_ok = to_response::(resp)?; + + let params = GetAccountParams { + refresh_token: false, + }; + let request_id = mcp.send_get_account_request(params).await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(resp)?; + + let expected = GetAccountResponse { + account: Some(Account::ApiKey {}), + requires_openai_auth: true, + }; + assert_eq!(received, expected); + Ok(()) +} + +#[tokio::test] +async fn get_account_when_auth_not_required() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(false), + ..Default::default() + }, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let params = GetAccountParams { + refresh_token: false, + }; + let request_id = mcp.send_get_account_request(params).await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(resp)?; + + let expected = GetAccountResponse { + account: None, + requires_openai_auth: false, + }; + assert_eq!(received, expected); + Ok(()) +} + +#[tokio::test] +async fn get_account_with_chatgpt() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + ..Default::default() + }, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt") + .email("user@example.com") + .plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let params = GetAccountParams { + refresh_token: false, + }; + let request_id = mcp.send_get_account_request(params).await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(resp)?; + + let expected = GetAccountResponse { + account: Some(Account::Chatgpt { + email: "user@example.com".to_string(), + plan_type: AccountPlanType::Pro, + }), + requires_openai_auth: true, + }; + assert_eq!(received, expected); + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs new file mode 100644 index 0000000000..587afef108 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -0,0 +1,9 @@ +mod account; +mod model_list; +mod rate_limits; +mod thread_archive; +mod thread_list; +mod thread_resume; +mod thread_start; +mod turn_interrupt; +mod turn_start; diff --git a/codex-rs/app-server/tests/suite/v2/model_list.rs b/codex-rs/app-server/tests/suite/v2/model_list.rs new file mode 100644 index 0000000000..667d9e0012 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/model_list.rs @@ -0,0 +1,186 @@ +use std::time::Duration; + +use anyhow::Result; +use anyhow::anyhow; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::Model; +use codex_app_server_protocol::ModelListParams; +use codex_app_server_protocol::ModelListResponse; +use codex_app_server_protocol::ReasoningEffortOption; +use codex_app_server_protocol::RequestId; +use codex_protocol::config_types::ReasoningEffort; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); +const INVALID_REQUEST_ERROR_CODE: i64 = -32600; + +#[tokio::test] +async fn list_models_returns_all_models_with_large_limit() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_list_models_request(ModelListParams { + limit: Some(100), + cursor: None, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let ModelListResponse { + data: items, + next_cursor, + } = to_response::(response)?; + + let expected_models = vec![ + Model { + id: "gpt-5-codex".to_string(), + model: "gpt-5-codex".to_string(), + display_name: "gpt-5-codex".to_string(), + description: "Optimized for codex.".to_string(), + supported_reasoning_efforts: vec![ + ReasoningEffortOption { + reasoning_effort: ReasoningEffort::Low, + description: "Fastest responses with limited reasoning".to_string(), + }, + ReasoningEffortOption { + reasoning_effort: ReasoningEffort::Medium, + description: "Dynamically adjusts reasoning based on the task".to_string(), + }, + ReasoningEffortOption { + reasoning_effort: ReasoningEffort::High, + description: "Maximizes reasoning depth for complex or ambiguous problems" + .to_string(), + }, + ], + default_reasoning_effort: ReasoningEffort::Medium, + is_default: true, + }, + Model { + id: "gpt-5".to_string(), + model: "gpt-5".to_string(), + display_name: "gpt-5".to_string(), + description: "Broad world knowledge with strong general reasoning.".to_string(), + supported_reasoning_efforts: vec![ + ReasoningEffortOption { + reasoning_effort: ReasoningEffort::Minimal, + description: "Fastest responses with little reasoning".to_string(), + }, + ReasoningEffortOption { + reasoning_effort: ReasoningEffort::Low, + description: "Balances speed with some reasoning; useful for straightforward \ + queries and short explanations" + .to_string(), + }, + ReasoningEffortOption { + reasoning_effort: ReasoningEffort::Medium, + description: "Provides a solid balance of reasoning depth and latency for \ + general-purpose tasks" + .to_string(), + }, + ReasoningEffortOption { + reasoning_effort: ReasoningEffort::High, + description: "Maximizes reasoning depth for complex or ambiguous problems" + .to_string(), + }, + ], + default_reasoning_effort: ReasoningEffort::Medium, + is_default: false, + }, + ]; + + assert_eq!(items, expected_models); + assert!(next_cursor.is_none()); + Ok(()) +} + +#[tokio::test] +async fn list_models_pagination_works() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let first_request = mcp + .send_list_models_request(ModelListParams { + limit: Some(1), + cursor: None, + }) + .await?; + + let first_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_request)), + ) + .await??; + + let ModelListResponse { + data: first_items, + next_cursor: first_cursor, + } = to_response::(first_response)?; + + assert_eq!(first_items.len(), 1); + assert_eq!(first_items[0].id, "gpt-5-codex"); + let next_cursor = first_cursor.ok_or_else(|| anyhow!("cursor for second page"))?; + + let second_request = mcp + .send_list_models_request(ModelListParams { + limit: Some(1), + cursor: Some(next_cursor.clone()), + }) + .await?; + + let second_response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_request)), + ) + .await??; + + let ModelListResponse { + data: second_items, + next_cursor: second_cursor, + } = to_response::(second_response)?; + + assert_eq!(second_items.len(), 1); + assert_eq!(second_items[0].id, "gpt-5"); + assert!(second_cursor.is_none()); + Ok(()) +} + +#[tokio::test] +async fn list_models_rejects_invalid_cursor() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_list_models_request(ModelListParams { + limit: None, + cursor: Some("invalid".to_string()), + }) + .await?; + + let error: JSONRPCError = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.id, RequestId::Integer(request_id)); + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!(error.error.message, "invalid cursor: invalid"); + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs new file mode 100644 index 0000000000..d0cba83667 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -0,0 +1,181 @@ +use anyhow::Result; +use app_test_support::ChatGptAuthFixture; +use app_test_support::McpProcess; +use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::GetAccountRateLimitsResponse; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::LoginApiKeyParams; +use codex_app_server_protocol::RateLimitSnapshot; +use codex_app_server_protocol::RateLimitWindow; +use codex_app_server_protocol::RequestId; +use codex_core::auth::AuthCredentialsStoreMode; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +const INVALID_REQUEST_ERROR_CODE: i64 = -32600; + +#[tokio::test] +async fn get_account_rate_limits_requires_auth() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_get_account_rate_limits_request().await?; + + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.id, RequestId::Integer(request_id)); + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + error.error.message, + "codex account authentication required to read rate limits" + ); + + Ok(()) +} + +#[tokio::test] +async fn get_account_rate_limits_requires_chatgpt_auth() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + login_with_api_key(&mut mcp, "sk-test-key").await?; + + let request_id = mcp.send_get_account_rate_limits_request().await?; + + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.id, RequestId::Integer(request_id)); + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + error.error.message, + "chatgpt authentication required to read rate limits" + ); + + Ok(()) +} + +#[tokio::test] +async fn get_account_rate_limits_returns_snapshot() -> Result<()> { + let codex_home = TempDir::new()?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + let server_url = server.uri(); + write_chatgpt_base_url(codex_home.path(), &server_url)?; + + let primary_reset_timestamp = chrono::DateTime::parse_from_rfc3339("2025-01-01T00:02:00Z") + .expect("parse primary reset timestamp") + .timestamp(); + let secondary_reset_timestamp = chrono::DateTime::parse_from_rfc3339("2025-01-01T01:00:00Z") + .expect("parse secondary reset timestamp") + .timestamp(); + let response_body = json!({ + "plan_type": "pro", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 42, + "limit_window_seconds": 3600, + "reset_after_seconds": 120, + "reset_at": primary_reset_timestamp, + }, + "secondary_window": { + "used_percent": 5, + "limit_window_seconds": 86400, + "reset_after_seconds": 43200, + "reset_at": secondary_reset_timestamp, + } + } + }); + + Mock::given(method("GET")) + .and(path("/api/codex/usage")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(response_body)) + .mount(&server) + .await; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_get_account_rate_limits_request().await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let received: GetAccountRateLimitsResponse = to_response(response)?; + + let expected = GetAccountRateLimitsResponse { + rate_limits: RateLimitSnapshot { + primary: Some(RateLimitWindow { + used_percent: 42, + window_duration_mins: Some(60), + resets_at: Some(primary_reset_timestamp), + }), + secondary: Some(RateLimitWindow { + used_percent: 5, + window_duration_mins: Some(1440), + resets_at: Some(secondary_reset_timestamp), + }), + }, + }; + assert_eq!(received, expected); + + Ok(()) +} + +async fn login_with_api_key(mcp: &mut McpProcess, api_key: &str) -> Result<()> { + let request_id = mcp + .send_login_api_key_request(LoginApiKeyParams { + api_key: api_key.to_string(), + }) + .await?; + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + Ok(()) +} + +fn write_chatgpt_base_url(codex_home: &Path, base_url: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write(config_toml, format!("chatgpt_base_url = \"{base_url}\"\n")) +} diff --git a/codex-rs/app-server/tests/suite/v2/thread_archive.rs b/codex-rs/app-server/tests/suite/v2/thread_archive.rs new file mode 100644 index 0000000000..083f3da901 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/thread_archive.rs @@ -0,0 +1,93 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadArchiveParams; +use codex_app_server_protocol::ThreadArchiveResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_core::ARCHIVED_SESSIONS_SUBDIR; +use codex_core::find_conversation_path_by_id_str; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn thread_archive_moves_rollout_into_archived_directory() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Start a thread. + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".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)?; + assert!(!thread.id.is_empty()); + + // Locate the rollout path recorded for this thread id. + let rollout_path = find_conversation_path_by_id_str(codex_home.path(), &thread.id) + .await? + .expect("expected rollout path for thread id to exist"); + assert!( + rollout_path.exists(), + "expected {} to exist", + rollout_path.display() + ); + + // Archive the thread. + let archive_id = mcp + .send_thread_archive_request(ThreadArchiveParams { + thread_id: thread.id.clone(), + }) + .await?; + let archive_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(archive_id)), + ) + .await??; + let _: ThreadArchiveResponse = to_response::(archive_resp)?; + + // Verify file moved. + let archived_directory = codex_home.path().join(ARCHIVED_SESSIONS_SUBDIR); + // The archived file keeps the original filename (rollout-...-.jsonl). + let archived_rollout_path = + archived_directory.join(rollout_path.file_name().expect("rollout file name")); + assert!( + !rollout_path.exists(), + "expected rollout path {} to be moved", + rollout_path.display() + ); + assert!( + archived_rollout_path.exists(), + "expected archived rollout path {} to exist", + archived_rollout_path.display() + ); + + Ok(()) +} + +fn create_config_toml(codex_home: &Path) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write(config_toml, config_contents()) +} + +fn config_contents() -> &'static str { + r#"model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" +"# +} diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs new file mode 100644 index 0000000000..09ef0ebfd2 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -0,0 +1,220 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_fake_rollout; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadListParams; +use codex_app_server_protocol::ThreadListResponse; +use serde_json::json; +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_list_basic_empty() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // List threads in an empty CODEX_HOME; should return an empty page with nextCursor: null. + let list_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: Some(10), + model_providers: None, + }) + .await?; + let list_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let ThreadListResponse { data, next_cursor } = to_response::(list_resp)?; + assert!(data.is_empty()); + assert_eq!(next_cursor, None); + + Ok(()) +} + +// Minimal config.toml for listing. +fn create_minimal_config(codex_home: &std::path::Path) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + r#" +model = "mock-model" +approval_policy = "never" +"#, + ) +} + +#[tokio::test] +async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + // Create three rollouts so we can paginate with limit=2. + let _a = create_fake_rollout( + codex_home.path(), + "2025-01-02T12-00-00", + "2025-01-02T12:00:00Z", + "Hello", + Some("mock_provider"), + )?; + let _b = create_fake_rollout( + codex_home.path(), + "2025-01-01T13-00-00", + "2025-01-01T13:00:00Z", + "Hello", + Some("mock_provider"), + )?; + let _c = create_fake_rollout( + codex_home.path(), + "2025-01-01T12-00-00", + "2025-01-01T12:00:00Z", + "Hello", + Some("mock_provider"), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Page 1: limit 2 → expect next_cursor Some. + let page1_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: Some(2), + model_providers: Some(vec!["mock_provider".to_string()]), + }) + .await?; + let page1_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(page1_id)), + ) + .await??; + let ThreadListResponse { + data: data1, + next_cursor: cursor1, + } = to_response::(page1_resp)?; + assert_eq!(data1.len(), 2); + for thread in &data1 { + assert_eq!(thread.preview, "Hello"); + assert_eq!(thread.model_provider, "mock_provider"); + assert!(thread.created_at > 0); + } + let cursor1 = cursor1.expect("expected nextCursor on first page"); + + // Page 2: with cursor → expect next_cursor None when no more results. + let page2_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: Some(cursor1), + limit: Some(2), + model_providers: Some(vec!["mock_provider".to_string()]), + }) + .await?; + let page2_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(page2_id)), + ) + .await??; + let ThreadListResponse { + data: data2, + next_cursor: cursor2, + } = to_response::(page2_resp)?; + assert!(data2.len() <= 2); + for thread in &data2 { + assert_eq!(thread.preview, "Hello"); + assert_eq!(thread.model_provider, "mock_provider"); + assert!(thread.created_at > 0); + } + assert_eq!(cursor2, None, "expected nextCursor to be null on last page"); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_respects_provider_filter() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + // Create rollouts under two providers. + let _a = create_fake_rollout( + codex_home.path(), + "2025-01-02T10-00-00", + "2025-01-02T10:00:00Z", + "X", + Some("mock_provider"), + )?; // mock_provider + // one with a different provider + let uuid = Uuid::new_v4(); + let dir = codex_home + .path() + .join("sessions") + .join("2025") + .join("01") + .join("02"); + std::fs::create_dir_all(&dir)?; + let file_path = dir.join(format!("rollout-2025-01-02T11-00-00-{uuid}.jsonl")); + let lines = [ + json!({ + "timestamp": "2025-01-02T11:00:00Z", + "type": "session_meta", + "payload": { + "id": uuid, + "timestamp": "2025-01-02T11:00:00Z", + "cwd": "/", + "originator": "codex", + "cli_version": "0.0.0", + "instructions": null, + "source": "vscode", + "model_provider": "other_provider" + } + }) + .to_string(), + json!({ + "timestamp": "2025-01-02T11:00:00Z", + "type":"response_item", + "payload": {"type":"message","role":"user","content":[{"type":"input_text","text":"X"}]} + }) + .to_string(), + json!({ + "timestamp": "2025-01-02T11:00:00Z", + "type":"event_msg", + "payload": {"type":"user_message","message":"X","kind":"plain"} + }) + .to_string(), + ]; + std::fs::write(file_path, lines.join("\n") + "\n")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Filter to only other_provider; expect 1 item, nextCursor None. + let list_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: Some(10), + model_providers: Some(vec!["other_provider".to_string()]), + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let ThreadListResponse { data, next_cursor } = to_response::(resp)?; + assert_eq!(data.len(), 1); + assert_eq!(next_cursor, None); + let thread = &data[0]; + assert_eq!(thread.preview, "X"); + assert_eq!(thread.model_provider, "other_provider"); + let expected_ts = chrono::DateTime::parse_from_rfc3339("2025-01-02T11:00:00Z")?.timestamp(); + assert_eq!(thread.created_at, expected_ts); + + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs new file mode 100644 index 0000000000..45fd974a97 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -0,0 +1,79 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_mock_chat_completions_server; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadResumeParams; +use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn thread_resume_returns_existing_thread() -> Result<()> { + let server = create_mock_chat_completions_server(vec![]).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Start a thread. + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5-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)?; + + // Resume it via v2 API. + let resume_id = mcp + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { thread: resumed } = + to_response::(resume_resp)?; + assert_eq!(resumed, thread); + + Ok(()) +} + +// Helper to create a config.toml pointing at the mock model server. +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "chat" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs new file mode 100644 index 0000000000..ac2420916b --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -0,0 +1,90 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_mock_chat_completions_server; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::ThreadStartedNotification; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn thread_start_creates_thread_and_emits_started() -> Result<()> { + // Provide a mock server and config so model wiring is valid. + let server = create_mock_chat_completions_server(vec![]).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + // Start server and initialize. + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Start a v2 thread with an explicit model override. + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5".to_string()), + ..Default::default() + }) + .await?; + + // Expect a proper JSON-RPC response with a thread id. + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { thread } = to_response::(resp)?; + assert!(!thread.id.is_empty(), "thread id should not be empty"); + assert!( + thread.preview.is_empty(), + "new threads should start with an empty preview" + ); + assert_eq!(thread.model_provider, "mock_provider"); + assert!( + thread.created_at > 0, + "created_at should be a positive UNIX timestamp" + ); + + // A corresponding thread/started notification should arrive. + let notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("thread/started"), + ) + .await??; + let started: ThreadStartedNotification = + serde_json::from_value(notif.params.expect("params must be present"))?; + assert_eq!(started.thread, thread); + + Ok(()) +} + +// Helper to create a config.toml pointing at the mock model server. +fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "chat" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs new file mode 100644 index 0000000000..d1deb60801 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -0,0 +1,128 @@ +#![cfg(unix)] + +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_mock_chat_completions_server; +use app_test_support::create_shell_sse_response; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnInterruptParams; +use codex_app_server_protocol::TurnInterruptResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn turn_interrupt_aborts_running_turn() -> Result<()> { + // Use a portable sleep command to keep the turn running. + #[cfg(target_os = "windows")] + let shell_command = vec![ + "powershell".to_string(), + "-Command".to_string(), + "Start-Sleep -Seconds 10".to_string(), + ]; + #[cfg(not(target_os = "windows"))] + let shell_command = vec!["sleep".to_string(), "10".to_string()]; + + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let working_directory = tmp.path().join("workdir"); + std::fs::create_dir(&working_directory)?; + + // Mock server: long-running shell command then (after abort) nothing else needed. + let server = create_mock_chat_completions_server(vec![create_shell_sse_response( + shell_command.clone(), + Some(&working_directory), + Some(10_000), + "call_sleep", + )?]) + .await; + create_config_toml(&codex_home, &server.uri())?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Start a v2 thread and capture its id. + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread } = to_response::(thread_resp)?; + + // Start a turn that triggers a long-running command. + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run sleep".to_string(), + }], + cwd: Some(working_directory.clone()), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + // Give the command a brief moment to start. + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + + // Interrupt the in-progress turn by id (v2 API). + let interrupt_id = mcp + .send_turn_interrupt_request(TurnInterruptParams { + thread_id: thread.id, + turn_id: turn.id, + }) + .await?; + let interrupt_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(interrupt_id)), + ) + .await??; + let _resp: TurnInterruptResponse = to_response::(interrupt_resp)?; + + // No fields to assert on; successful deserialization confirms proper response shape. + Ok(()) +} + +// Helper to create a config.toml pointing at the mock model server. +fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "never" +sandbox_mode = "workspace-write" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "chat" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs new file mode 100644 index 0000000000..b26f01a9f1 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -0,0 +1,486 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_final_assistant_message_sse_response; +use app_test_support::create_mock_chat_completions_server; +use app_test_support::create_mock_chat_completions_server_unchecked; +use app_test_support::create_shell_sse_response; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCNotification; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::TurnStartedNotification; +use codex_app_server_protocol::UserInput as V2UserInput; +use codex_core::protocol_config_types::ReasoningEffort; +use codex_core::protocol_config_types::ReasoningSummary; +use codex_protocol::parse_command::ParsedCommand; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use core_test_support::skip_if_no_network; +use pretty_assertions::assert_eq; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<()> { + // Provide a mock server and config so model wiring is valid. + // Three Codex turns hit the mock model (session start + two turn/start calls). + let responses = vec![ + create_final_assistant_message_sse_response("Done")?, + create_final_assistant_message_sse_response("Done")?, + create_final_assistant_message_sse_response("Done")?, + ]; + let server = create_mock_chat_completions_server_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // Start a thread (v2) and capture its id. + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread } = to_response::(thread_resp)?; + + // Start a turn with only input and thread_id set (no overrides). + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + assert!(!turn.id.is_empty()); + + // Expect a turn/started notification. + let notif: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + let started: TurnStartedNotification = + serde_json::from_value(notif.params.expect("params must be present"))?; + assert_eq!( + started.turn.status, + codex_app_server_protocol::TurnStatus::InProgress + ); + + // Send a second turn that exercises the overrides path: change the model. + let turn_req2 = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "Second".to_string(), + }], + model: Some("mock-model-override".to_string()), + ..Default::default() + }) + .await?; + let turn_resp2: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req2)), + ) + .await??; + let TurnStartResponse { turn: turn2 } = to_response::(turn_resp2)?; + assert!(!turn2.id.is_empty()); + // Ensure the second turn has a different id than the first. + assert_ne!(turn.id, turn2.id); + + // Expect a second turn/started notification as well. + let _notif2: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/started"), + ) + .await??; + + // And we should ultimately get a task_complete without having to add a + // legacy conversation listener explicitly (auto-attached by thread/start). + let _task_complete: JSONRPCNotification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/task_complete"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn turn_start_accepts_local_image_input() -> Result<()> { + // Two Codex turns hit the mock model (session start + turn/start). + let responses = vec![ + create_final_assistant_message_sse_response("Done")?, + create_final_assistant_message_sse_response("Done")?, + ]; + // Use the unchecked variant because the request payload includes a LocalImage + // which the strict matcher does not currently cover. + let server = create_mock_chat_completions_server_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread } = to_response::(thread_resp)?; + + let image_path = codex_home.path().join("image.png"); + // No need to actually write the file; we just exercise the input path. + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::LocalImage { path: image_path }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + assert!(!turn.id.is_empty()); + + // This test only validates that turn/start responds and returns a turn. + Ok(()) +} + +#[tokio::test] +async fn turn_start_exec_approval_toggle_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let tmp = TempDir::new()?; + let codex_home = tmp.path().to_path_buf(); + + // Mock server: first turn requests a shell call (elicitation), then completes. + // Second turn same, but we'll set approval_policy=never to avoid elicitation. + let responses = vec![ + create_shell_sse_response( + vec![ + "python3".to_string(), + "-c".to_string(), + "print(42)".to_string(), + ], + None, + Some(5000), + "call1", + )?, + create_final_assistant_message_sse_response("done 1")?, + create_shell_sse_response( + vec![ + "python3".to_string(), + "-c".to_string(), + "print(42)".to_string(), + ], + None, + Some(5000), + "call2", + )?, + create_final_assistant_message_sse_response("done 2")?, + ]; + let server = create_mock_chat_completions_server(responses).await; + // Default approval is untrusted to force elicitation on first turn. + create_config_toml(codex_home.as_path(), &server.uri(), "untrusted")?; + + let mut mcp = McpProcess::new(codex_home.as_path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // thread/start + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".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)?; + + // turn/start — expect ExecCommandApproval request from server + let first_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run python".to_string(), + }], + ..Default::default() + }) + .await?; + // Acknowledge RPC + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn_id)), + ) + .await??; + + // Receive elicitation + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::ExecCommandApproval { request_id, params } = server_req else { + panic!("expected ExecCommandApproval request"); + }; + assert_eq!(params.call_id, "call1"); + assert_eq!( + params.parsed_cmd, + vec![ParsedCommand::Unknown { + cmd: "python3 -c 'print(42)'".to_string() + }] + ); + + // Approve and wait for task completion + mcp.send_response( + request_id, + serde_json::json!({ "decision": codex_core::protocol::ReviewDecision::Approved }), + ) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/task_complete"), + ) + .await??; + + // Second turn with approval_policy=never should not elicit approval + let second_turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run python again".to_string(), + }], + approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), + model: Some("mock-model".to_string()), + effort: Some(ReasoningEffort::Medium), + summary: Some(ReasoningSummary::Auto), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn_id)), + ) + .await??; + + // Ensure we do NOT receive an ExecCommandApproval request before task completes + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/task_complete"), + ) + .await??; + + Ok(()) +} + +#[tokio::test] +async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { + // When returning Result from a test, pass an Ok(()) to the skip macro + // so the early return type matches. The no-arg form returns unit. + skip_if_no_network!(Ok(())); + + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let workspace_root = tmp.path().join("workspace"); + std::fs::create_dir(&workspace_root)?; + let first_cwd = workspace_root.join("turn1"); + let second_cwd = workspace_root.join("turn2"); + std::fs::create_dir(&first_cwd)?; + std::fs::create_dir(&second_cwd)?; + + let responses = vec![ + create_shell_sse_response( + vec![ + "bash".to_string(), + "-lc".to_string(), + "echo first turn".to_string(), + ], + None, + Some(5000), + "call-first", + )?, + create_final_assistant_message_sse_response("done first")?, + create_shell_sse_response( + vec![ + "bash".to_string(), + "-lc".to_string(), + "echo second turn".to_string(), + ], + None, + Some(5000), + "call-second", + )?, + create_final_assistant_message_sse_response("done second")?, + ]; + let server = create_mock_chat_completions_server(responses).await; + create_config_toml(&codex_home, &server.uri(), "untrusted")?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + // thread/start + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".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)?; + + // first turn with workspace-write sandbox and first_cwd + let first_turn = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + }], + cwd: Some(first_cwd.clone()), + approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { + writable_roots: vec![first_cwd.clone()], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }), + model: Some("mock-model".to_string()), + effort: Some(ReasoningEffort::Medium), + summary: Some(ReasoningSummary::Auto), + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/task_complete"), + ) + .await??; + + // second turn with workspace-write and second_cwd, ensure exec begins in second_cwd + let second_turn = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + }], + cwd: Some(second_cwd.clone()), + approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), + model: Some("mock-model".to_string()), + effort: Some(ReasoningEffort::Medium), + summary: Some(ReasoningSummary::Auto), + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn)), + ) + .await??; + + let exec_begin_notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/exec_command_begin"), + ) + .await??; + let params = exec_begin_notification + .params + .clone() + .expect("exec_command_begin params"); + let event: Event = serde_json::from_value(params).expect("deserialize exec begin event"); + let exec_begin = match event.msg { + EventMsg::ExecCommandBegin(exec_begin) => exec_begin, + other => panic!("expected ExecCommandBegin event, got {other:?}"), + }; + assert_eq!(exec_begin.cwd, second_cwd); + assert_eq!( + exec_begin.command, + vec![ + "bash".to_string(), + "-lc".to_string(), + "echo second turn".to_string() + ] + ); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/task_complete"), + ) + .await??; + + Ok(()) +} + +// Helper to create a config.toml pointing at the mock model server. +fn create_config_toml( + codex_home: &Path, + server_uri: &str, + approval_policy: &str, +) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + format!( + r#" +model = "mock-model" +approval_policy = "{approval_policy}" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "{server_uri}/v1" +wire_api = "chat" +request_max_retries = 0 +stream_max_retries = 0 +"# + ), + ) +} diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 3d0052ed03..ac2f409791 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -288,7 +288,7 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp path, ApplyPatchFileChange::Update { unified_diff, - move_path: move_path.map(|p| cwd.join(p)), + move_path: move_path.map(|p| effective_cwd.join(p)), new_content: contents, }, ); @@ -1603,6 +1603,53 @@ g ); } + #[test] + fn test_apply_patch_resolves_move_path_with_effective_cwd() { + let session_dir = tempdir().unwrap(); + let worktree_rel = "alt"; + let worktree_dir = session_dir.path().join(worktree_rel); + fs::create_dir_all(&worktree_dir).unwrap(); + + let source_name = "old.txt"; + let dest_name = "renamed.txt"; + let source_path = worktree_dir.join(source_name); + fs::write(&source_path, "before\n").unwrap(); + + let patch = wrap_patch(&format!( + r#"*** Update File: {source_name} +*** Move to: {dest_name} +@@ +-before ++after"# + )); + + let shell_script = format!("cd {worktree_rel} && apply_patch <<'PATCH'\n{patch}\nPATCH"); + let argv = vec!["bash".into(), "-lc".into(), shell_script]; + + let result = maybe_parse_apply_patch_verified(&argv, session_dir.path()); + let action = match result { + MaybeApplyPatchVerified::Body(action) => action, + other => panic!("expected verified body, got {other:?}"), + }; + + assert_eq!(action.cwd, worktree_dir); + + let change = action + .changes() + .get(&worktree_dir.join(source_name)) + .expect("source file change present"); + + match change { + ApplyPatchFileChange::Update { move_path, .. } => { + assert_eq!( + move_path.as_deref(), + Some(worktree_dir.join(dest_name).as_path()) + ); + } + other => panic!("expected update change, got {other:?}"), + } + } + #[test] fn test_apply_patch_fails_on_write_error() { let dir = tempdir().unwrap(); diff --git a/codex-rs/apply-patch/tests/suite/mod.rs b/codex-rs/apply-patch/tests/suite/mod.rs index 26710c101c..882c5a6ffd 100644 --- a/codex-rs/apply-patch/tests/suite/mod.rs +++ b/codex-rs/apply-patch/tests/suite/mod.rs @@ -1 +1,3 @@ mod cli; +#[cfg(not(target_os = "windows"))] +mod tool; diff --git a/codex-rs/apply-patch/tests/suite/tool.rs b/codex-rs/apply-patch/tests/suite/tool.rs new file mode 100644 index 0000000000..c937719da0 --- /dev/null +++ b/codex-rs/apply-patch/tests/suite/tool.rs @@ -0,0 +1,257 @@ +use assert_cmd::Command; +use pretty_assertions::assert_eq; +use std::fs; +use std::path::Path; +use tempfile::tempdir; + +fn run_apply_patch_in_dir(dir: &Path, patch: &str) -> anyhow::Result { + let mut cmd = Command::cargo_bin("apply_patch")?; + cmd.current_dir(dir); + Ok(cmd.arg(patch).assert()) +} + +fn apply_patch_command(dir: &Path) -> anyhow::Result { + let mut cmd = Command::cargo_bin("apply_patch")?; + cmd.current_dir(dir); + Ok(cmd) +} + +#[test] +fn test_apply_patch_cli_applies_multiple_operations() -> anyhow::Result<()> { + let tmp = tempdir()?; + let modify_path = tmp.path().join("modify.txt"); + let delete_path = tmp.path().join("delete.txt"); + + fs::write(&modify_path, "line1\nline2\n")?; + fs::write(&delete_path, "obsolete\n")?; + + let patch = "*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch"; + + run_apply_patch_in_dir(tmp.path(), patch)?.success().stdout( + "Success. Updated the following files:\nA nested/new.txt\nM modify.txt\nD delete.txt\n", + ); + + assert_eq!( + fs::read_to_string(tmp.path().join("nested/new.txt"))?, + "created\n" + ); + assert_eq!(fs::read_to_string(&modify_path)?, "line1\nchanged\n"); + assert!(!delete_path.exists()); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_applies_multiple_chunks() -> anyhow::Result<()> { + let tmp = tempdir()?; + let target_path = tmp.path().join("multi.txt"); + fs::write(&target_path, "line1\nline2\nline3\nline4\n")?; + + let patch = "*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch"; + + run_apply_patch_in_dir(tmp.path(), patch)? + .success() + .stdout("Success. Updated the following files:\nM multi.txt\n"); + + assert_eq!( + fs::read_to_string(&target_path)?, + "line1\nchanged2\nline3\nchanged4\n" + ); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_moves_file_to_new_directory() -> anyhow::Result<()> { + let tmp = tempdir()?; + let original_path = tmp.path().join("old/name.txt"); + let new_path = tmp.path().join("renamed/dir/name.txt"); + fs::create_dir_all(original_path.parent().expect("parent should exist"))?; + fs::write(&original_path, "old content\n")?; + + let patch = "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch"; + + run_apply_patch_in_dir(tmp.path(), patch)? + .success() + .stdout("Success. Updated the following files:\nM renamed/dir/name.txt\n"); + + assert!(!original_path.exists()); + assert_eq!(fs::read_to_string(&new_path)?, "new content\n"); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_rejects_empty_patch() -> anyhow::Result<()> { + let tmp = tempdir()?; + + apply_patch_command(tmp.path())? + .arg("*** Begin Patch\n*** End Patch") + .assert() + .failure() + .stderr("No files were modified.\n"); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_reports_missing_context() -> anyhow::Result<()> { + let tmp = tempdir()?; + let target_path = tmp.path().join("modify.txt"); + fs::write(&target_path, "line1\nline2\n")?; + + apply_patch_command(tmp.path())? + .arg("*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch") + .assert() + .failure() + .stderr("Failed to find expected lines in modify.txt:\nmissing\n"); + assert_eq!(fs::read_to_string(&target_path)?, "line1\nline2\n"); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_rejects_missing_file_delete() -> anyhow::Result<()> { + let tmp = tempdir()?; + + apply_patch_command(tmp.path())? + .arg("*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch") + .assert() + .failure() + .stderr("Failed to delete file missing.txt\n"); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_rejects_empty_update_hunk() -> anyhow::Result<()> { + let tmp = tempdir()?; + + apply_patch_command(tmp.path())? + .arg("*** Begin Patch\n*** Update File: foo.txt\n*** End Patch") + .assert() + .failure() + .stderr("Invalid patch hunk on line 2: Update file hunk for path 'foo.txt' is empty\n"); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_requires_existing_file_for_update() -> anyhow::Result<()> { + let tmp = tempdir()?; + + apply_patch_command(tmp.path())? + .arg("*** Begin Patch\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch") + .assert() + .failure() + .stderr( + "Failed to read file to update missing.txt: No such file or directory (os error 2)\n", + ); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_move_overwrites_existing_destination() -> anyhow::Result<()> { + let tmp = tempdir()?; + let original_path = tmp.path().join("old/name.txt"); + let destination = tmp.path().join("renamed/dir/name.txt"); + fs::create_dir_all(original_path.parent().expect("parent should exist"))?; + fs::create_dir_all(destination.parent().expect("parent should exist"))?; + fs::write(&original_path, "from\n")?; + fs::write(&destination, "existing\n")?; + + run_apply_patch_in_dir( + tmp.path(), + "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch", + )? + .success() + .stdout("Success. Updated the following files:\nM renamed/dir/name.txt\n"); + + assert!(!original_path.exists()); + assert_eq!(fs::read_to_string(&destination)?, "new\n"); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_add_overwrites_existing_file() -> anyhow::Result<()> { + let tmp = tempdir()?; + let path = tmp.path().join("duplicate.txt"); + fs::write(&path, "old content\n")?; + + run_apply_patch_in_dir( + tmp.path(), + "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch", + )? + .success() + .stdout("Success. Updated the following files:\nA duplicate.txt\n"); + + assert_eq!(fs::read_to_string(&path)?, "new content\n"); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_delete_directory_fails() -> anyhow::Result<()> { + let tmp = tempdir()?; + fs::create_dir(tmp.path().join("dir"))?; + + apply_patch_command(tmp.path())? + .arg("*** Begin Patch\n*** Delete File: dir\n*** End Patch") + .assert() + .failure() + .stderr("Failed to delete file dir\n"); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_rejects_invalid_hunk_header() -> anyhow::Result<()> { + let tmp = tempdir()?; + + apply_patch_command(tmp.path())? + .arg("*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch") + .assert() + .failure() + .stderr("Invalid patch hunk on line 2: '*** Frobnicate File: foo' is not a valid hunk header. Valid hunk headers: '*** Add File: {path}', '*** Delete File: {path}', '*** Update File: {path}'\n"); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_updates_file_appends_trailing_newline() -> anyhow::Result<()> { + let tmp = tempdir()?; + let target_path = tmp.path().join("no_newline.txt"); + fs::write(&target_path, "no newline at end")?; + + run_apply_patch_in_dir( + tmp.path(), + "*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch", + )? + .success() + .stdout("Success. Updated the following files:\nM no_newline.txt\n"); + + let contents = fs::read_to_string(&target_path)?; + assert!(contents.ends_with('\n')); + assert_eq!(contents, "first line\nsecond line\n"); + + Ok(()) +} + +#[test] +fn test_apply_patch_cli_failure_after_partial_success_leaves_changes() -> anyhow::Result<()> { + let tmp = tempdir()?; + let new_file = tmp.path().join("created.txt"); + + apply_patch_command(tmp.path())? + .arg("*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch") + .assert() + .failure() + .stdout("") + .stderr("Failed to read file to update missing.txt: No such file or directory (os error 2)\n"); + + assert_eq!(fs::read_to_string(&new_file)?, "hello\n"); + + Ok(()) +} diff --git a/codex-rs/async-utils/Cargo.toml b/codex-rs/async-utils/Cargo.toml new file mode 100644 index 0000000000..5203db0f5d --- /dev/null +++ b/codex-rs/async-utils/Cargo.toml @@ -0,0 +1,15 @@ +[package] +edition.workspace = true +name = "codex-async-utils" +version.workspace = true + +[lints] +workspace = true + +[dependencies] +async-trait.workspace = true +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] } +tokio-util.workspace = true + +[dev-dependencies] +pretty_assertions.workspace = true diff --git a/codex-rs/async-utils/src/lib.rs b/codex-rs/async-utils/src/lib.rs new file mode 100644 index 0000000000..bd880ae1fb --- /dev/null +++ b/codex-rs/async-utils/src/lib.rs @@ -0,0 +1,86 @@ +use async_trait::async_trait; +use std::future::Future; +use tokio_util::sync::CancellationToken; + +#[derive(Debug, PartialEq, Eq)] +pub enum CancelErr { + Cancelled, +} + +#[async_trait] +pub trait OrCancelExt: Sized { + type Output; + + async fn or_cancel(self, token: &CancellationToken) -> Result; +} + +#[async_trait] +impl OrCancelExt for F +where + F: Future + Send, + F::Output: Send, +{ + type Output = F::Output; + + async fn or_cancel(self, token: &CancellationToken) -> Result { + tokio::select! { + _ = token.cancelled() => Err(CancelErr::Cancelled), + res = self => Ok(res), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::time::Duration; + use tokio::task; + use tokio::time::sleep; + + #[tokio::test] + async fn returns_ok_when_future_completes_first() { + let token = CancellationToken::new(); + let value = async { 42 }; + + let result = value.or_cancel(&token).await; + + assert_eq!(Ok(42), result); + } + + #[tokio::test] + async fn returns_err_when_token_cancelled_first() { + let token = CancellationToken::new(); + let token_clone = token.clone(); + + let cancel_handle = task::spawn(async move { + sleep(Duration::from_millis(10)).await; + token_clone.cancel(); + }); + + let result = async { + sleep(Duration::from_millis(100)).await; + 7 + } + .or_cancel(&token) + .await; + + cancel_handle.await.expect("cancel task panicked"); + assert_eq!(Err(CancelErr::Cancelled), result); + } + + #[tokio::test] + async fn returns_err_when_token_already_cancelled() { + let token = CancellationToken::new(); + token.cancel(); + + let result = async { + sleep(Duration::from_millis(50)).await; + 5 + } + .or_cancel(&token) + .await; + + assert_eq!(Err(CancelErr::Cancelled), result); + } +} diff --git a/codex-rs/backend-client/Cargo.toml b/codex-rs/backend-client/Cargo.toml index a8e2ee85fb..0cf802399c 100644 --- a/codex-rs/backend-client/Cargo.toml +++ b/codex-rs/backend-client/Cargo.toml @@ -13,6 +13,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } codex-backend-openapi-models = { path = "../codex-backend-openapi-models" } +codex-protocol = { workspace = true } +codex-core = { workspace = true } [dev-dependencies] pretty_assertions = "1" diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 06ad00cb80..28a51598eb 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -1,7 +1,13 @@ use crate::types::CodeTaskDetailsResponse; use crate::types::PaginatedListTaskListItem; +use crate::types::RateLimitStatusPayload; +use crate::types::RateLimitWindowSnapshot; use crate::types::TurnAttemptsSiblingTurnsResponse; use anyhow::Result; +use codex_core::auth::CodexAuth; +use codex_core::default_client::get_codex_user_agent; +use codex_protocol::protocol::RateLimitSnapshot; +use codex_protocol::protocol::RateLimitWindow; use reqwest::header::AUTHORIZATION; use reqwest::header::CONTENT_TYPE; use reqwest::header::HeaderMap; @@ -64,6 +70,17 @@ impl Client { }) } + pub async fn from_auth(base_url: impl Into, auth: &CodexAuth) -> Result { + let token = auth.get_token().await.map_err(anyhow::Error::from)?; + let mut client = Self::new(base_url)? + .with_user_agent(get_codex_user_agent()) + .with_bearer_token(token); + if let Some(account_id) = auth.get_account_id() { + client = client.with_chatgpt_account_id(account_id); + } + Ok(client) + } + pub fn with_bearer_token(mut self, token: impl Into) -> Self { self.bearer_token = Some(token.into()); self @@ -138,6 +155,17 @@ impl Client { } } + pub async fn get_rate_limits(&self) -> Result { + let url = match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/usage", self.base_url), + PathStyle::ChatGptApi => format!("{}/wham/usage", self.base_url), + }; + let req = self.http.get(&url).headers(self.headers()); + let (body, ct) = self.exec_request(req, "GET", &url).await?; + let payload: RateLimitStatusPayload = self.decode_json(&url, &ct, &body)?; + Ok(Self::rate_limit_snapshot_from_payload(payload)) + } + pub async fn list_tasks( &self, limit: Option, @@ -241,4 +269,49 @@ impl Client { Err(e) => anyhow::bail!("Decode error for {url}: {e}; content-type={ct}; body={body}"), } } + + // rate limit helpers + fn rate_limit_snapshot_from_payload(payload: RateLimitStatusPayload) -> RateLimitSnapshot { + let Some(details) = payload + .rate_limit + .and_then(|inner| inner.map(|boxed| *boxed)) + else { + return RateLimitSnapshot { + primary: None, + secondary: None, + }; + }; + + RateLimitSnapshot { + primary: Self::map_rate_limit_window(details.primary_window), + secondary: Self::map_rate_limit_window(details.secondary_window), + } + } + + fn map_rate_limit_window( + window: Option>>, + ) -> Option { + let snapshot = match window { + Some(Some(snapshot)) => *snapshot, + _ => return None, + }; + + let used_percent = f64::from(snapshot.used_percent); + let window_minutes = Self::window_minutes_from_seconds(snapshot.limit_window_seconds); + let resets_at = Some(i64::from(snapshot.reset_at)); + Some(RateLimitWindow { + used_percent, + window_minutes, + resets_at, + }) + } + + fn window_minutes_from_seconds(seconds: i32) -> Option { + if seconds <= 0 { + return None; + } + + let seconds_i64 = i64::from(seconds); + Some((seconds_i64 + 59) / 60) + } } diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 8e2dfb2126..9f196f9c2f 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -1,4 +1,8 @@ pub use codex_backend_openapi_models::models::PaginatedListTaskListItem; +pub use codex_backend_openapi_models::models::PlanType; +pub use codex_backend_openapi_models::models::RateLimitStatusDetails; +pub use codex_backend_openapi_models::models::RateLimitStatusPayload; +pub use codex_backend_openapi_models::models::RateLimitWindowSnapshot; pub use codex_backend_openapi_models::models::TaskListItem; use serde::Deserialize; diff --git a/codex-rs/chatgpt/Cargo.toml b/codex-rs/chatgpt/Cargo.toml index 0a3170865a..c46046b1f6 100644 --- a/codex-rs/chatgpt/Cargo.toml +++ b/codex-rs/chatgpt/Cargo.toml @@ -14,7 +14,7 @@ codex-core = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tokio = { workspace = true, features = ["full"] } -codex-git-apply = { path = "../git-apply" } +codex-git = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/codex-rs/chatgpt/src/apply_command.rs b/codex-rs/chatgpt/src/apply_command.rs index ea7c69d572..ffd460e293 100644 --- a/codex-rs/chatgpt/src/apply_command.rs +++ b/codex-rs/chatgpt/src/apply_command.rs @@ -32,7 +32,8 @@ pub async fn run_apply_command( ) .await?; - init_chatgpt_token_from_auth(&config.codex_home).await?; + init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode) + .await?; let task_response = get_task(&config, apply_cli.task_id).await?; apply_diff_from_task(task_response, cwd).await @@ -58,13 +59,13 @@ pub async fn apply_diff_from_task( async fn apply_diff(diff: &str, cwd: Option) -> anyhow::Result<()> { let cwd = cwd.unwrap_or(std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir())); - let req = codex_git_apply::ApplyGitRequest { + let req = codex_git::ApplyGitRequest { cwd, diff: diff.to_string(), revert: false, preflight: false, }; - let res = codex_git_apply::apply_git_patch(&req)?; + let res = codex_git::apply_git_patch(&req)?; if res.exit_code != 0 { anyhow::bail!( "Git apply failed (applied={}, skipped={}, conflicts={})\nstdout:\n{}\nstderr:\n{}", diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs index d450f7c59e..7528631981 100644 --- a/codex-rs/chatgpt/src/chatgpt_client.rs +++ b/codex-rs/chatgpt/src/chatgpt_client.rs @@ -13,7 +13,8 @@ pub(crate) async fn chatgpt_get_request( path: String, ) -> anyhow::Result { let chatgpt_base_url = &config.chatgpt_base_url; - init_chatgpt_token_from_auth(&config.codex_home).await?; + init_chatgpt_token_from_auth(&config.codex_home, config.cli_auth_credentials_store_mode) + .await?; // Make direct HTTP request to ChatGPT backend API with the token let client = create_client(); diff --git a/codex-rs/chatgpt/src/chatgpt_token.rs b/codex-rs/chatgpt/src/chatgpt_token.rs index ce9b7475c9..e8879ad21c 100644 --- a/codex-rs/chatgpt/src/chatgpt_token.rs +++ b/codex-rs/chatgpt/src/chatgpt_token.rs @@ -3,6 +3,7 @@ use std::path::Path; use std::sync::LazyLock; use std::sync::RwLock; +use codex_core::auth::AuthCredentialsStoreMode; use codex_core::token_data::TokenData; static CHATGPT_TOKEN: LazyLock>> = LazyLock::new(|| RwLock::new(None)); @@ -18,8 +19,11 @@ pub fn set_chatgpt_token_data(value: TokenData) { } /// Initialize the ChatGPT token from auth.json file -pub async fn init_chatgpt_token_from_auth(codex_home: &Path) -> std::io::Result<()> { - let auth = CodexAuth::from_codex_home(codex_home)?; +pub async fn init_chatgpt_token_from_auth( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result<()> { + let auth = CodexAuth::from_auth_storage(codex_home, auth_credentials_store_mode)?; if let Some(auth) = auth { let token_data = auth.get_token_data().await?; set_chatgpt_token_data(token_data); diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 453ab80771..041644b34d 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -19,8 +19,10 @@ anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } clap_complete = { workspace = true } codex-app-server = { workspace = true } +codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-chatgpt = { workspace = true } +codex-cloud-tasks = { path = "../cloud-tasks" } codex-common = { workspace = true, features = ["cli"] } codex-core = { workspace = true } codex-exec = { workspace = true } @@ -28,16 +30,16 @@ codex-login = { workspace = true } codex-mcp-server = { workspace = true } codex-process-hardening = { workspace = true } codex-protocol = { workspace = true } -codex-app-server-protocol = { workspace = true } codex-protocol-ts = { workspace = true } codex-responses-api-proxy = { workspace = true } -codex-tui = { workspace = true } codex-rmcp-client = { workspace = true } -codex-cloud-tasks = { path = "../cloud-tasks" } +codex-stdio-to-uds = { workspace = true } +codex-tui = { workspace = true } ctor = { workspace = true } owo-colors = { workspace = true } serde_json = { workspace = true } supports-color = { workspace = true } +toml = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", @@ -46,9 +48,12 @@ tokio = { workspace = true, features = [ "signal", ] } +[target.'cfg(target_os = "windows")'.dependencies] +codex_windows_sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } + [dev-dependencies] -assert_matches = { workspace = true } assert_cmd = { workspace = true } +assert_matches = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index cde1f70839..6e7cd07701 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -5,14 +5,17 @@ use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::exec_env::create_env; use codex_core::landlock::spawn_command_under_linux_sandbox; +#[cfg(target_os = "macos")] use codex_core::seatbelt::spawn_command_under_seatbelt; use codex_core::spawn::StdioPolicy; use codex_protocol::config_types::SandboxMode; use crate::LandlockCommand; use crate::SeatbeltCommand; +use crate::WindowsCommand; use crate::exit_status::handle_exit_status; +#[cfg(target_os = "macos")] pub async fn run_command_under_seatbelt( command: SeatbeltCommand, codex_linux_sandbox_exe: Option, @@ -32,6 +35,14 @@ pub async fn run_command_under_seatbelt( .await } +#[cfg(not(target_os = "macos"))] +pub async fn run_command_under_seatbelt( + _command: SeatbeltCommand, + _codex_linux_sandbox_exe: Option, +) -> anyhow::Result<()> { + anyhow::bail!("Seatbelt sandbox is only available on macOS"); +} + pub async fn run_command_under_landlock( command: LandlockCommand, codex_linux_sandbox_exe: Option, @@ -51,9 +62,30 @@ pub async fn run_command_under_landlock( .await } +pub async fn run_command_under_windows( + command: WindowsCommand, + codex_linux_sandbox_exe: Option, +) -> anyhow::Result<()> { + let WindowsCommand { + full_auto, + config_overrides, + command, + } = command; + run_command_under_sandbox( + full_auto, + command, + config_overrides, + codex_linux_sandbox_exe, + SandboxType::Windows, + ) + .await +} + enum SandboxType { + #[cfg(target_os = "macos")] Seatbelt, Landlock, + Windows, } async fn run_command_under_sandbox( @@ -87,7 +119,67 @@ async fn run_command_under_sandbox( let stdio_policy = StdioPolicy::Inherit; let env = create_env(&config.shell_environment_policy); + // Special-case Windows sandbox: execute and exit the process to emulate inherited stdio. + if let SandboxType::Windows = sandbox_type { + #[cfg(target_os = "windows")] + { + use codex_windows_sandbox::run_windows_sandbox_capture; + + let policy_str = match &config.sandbox_policy { + codex_core::protocol::SandboxPolicy::DangerFullAccess => "workspace-write", + codex_core::protocol::SandboxPolicy::ReadOnly => "read-only", + codex_core::protocol::SandboxPolicy::WorkspaceWrite { .. } => "workspace-write", + }; + + let sandbox_cwd = sandbox_policy_cwd.clone(); + let cwd_clone = cwd.clone(); + let env_map = env.clone(); + let command_vec = command.clone(); + let base_dir = config.codex_home.clone(); + let res = tokio::task::spawn_blocking(move || { + run_windows_sandbox_capture( + policy_str, + &sandbox_cwd, + command_vec, + &cwd_clone, + env_map, + None, + Some(base_dir.as_path()), + ) + }) + .await; + + let capture = match res { + Ok(Ok(v)) => v, + Ok(Err(err)) => { + eprintln!("windows sandbox failed: {err}"); + std::process::exit(1); + } + Err(join_err) => { + eprintln!("windows sandbox join error: {join_err}"); + std::process::exit(1); + } + }; + + if !capture.stdout.is_empty() { + use std::io::Write; + let _ = std::io::stdout().write_all(&capture.stdout); + } + if !capture.stderr.is_empty() { + use std::io::Write; + let _ = std::io::stderr().write_all(&capture.stderr); + } + + std::process::exit(capture.exit_code); + } + #[cfg(not(target_os = "windows"))] + { + anyhow::bail!("Windows sandbox is only available on Windows"); + } + } + let mut child = match sandbox_type { + #[cfg(target_os = "macos")] SandboxType::Seatbelt => { spawn_command_under_seatbelt( command, @@ -115,6 +207,9 @@ async fn run_command_under_sandbox( ) .await? } + SandboxType::Windows => { + unreachable!("Windows sandbox should have been handled above"); + } }; let status = child.wait().await?; diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index c05d570e64..0cd2789663 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -32,3 +32,17 @@ pub struct LandlockCommand { #[arg(trailing_var_arg = true)] pub command: Vec, } + +#[derive(Debug, Parser)] +pub struct WindowsCommand { + /// Convenience alias for low-friction sandboxed automatic execution (network-disabled sandbox that can write to cwd and TMPDIR) + #[arg(long = "full-auto", default_value_t = false)] + pub full_auto: bool, + + #[clap(skip)] + pub config_overrides: CliConfigOverrides, + + /// Full command args to run under Windows restricted token sandbox. + #[arg(trailing_var_arg = true)] + pub command: Vec, +} diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 5e69ede69d..6681ab20c8 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -1,6 +1,7 @@ use codex_app_server_protocol::AuthMode; use codex_common::CliConfigOverrides; use codex_core::CodexAuth; +use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::CLIENT_ID; use codex_core::auth::login_with_api_key; use codex_core::auth::logout; @@ -9,12 +10,22 @@ use codex_core::config::ConfigOverrides; use codex_login::ServerOptions; use codex_login::run_device_code_login; use codex_login::run_login_server; +use codex_protocol::config_types::ForcedLoginMethod; use std::io::IsTerminal; use std::io::Read; use std::path::PathBuf; -pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> { - let opts = ServerOptions::new(codex_home, CLIENT_ID.to_string()); +pub async fn login_with_chatgpt( + codex_home: PathBuf, + forced_chatgpt_workspace_id: Option, + cli_auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result<()> { + let opts = ServerOptions::new( + codex_home, + CLIENT_ID.to_string(), + forced_chatgpt_workspace_id, + cli_auth_credentials_store_mode, + ); let server = run_login_server(opts)?; eprintln!( @@ -28,7 +39,20 @@ pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> { pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; - match login_with_chatgpt(config.codex_home).await { + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { + eprintln!("ChatGPT login is disabled. Use API key login instead."); + std::process::exit(1); + } + + let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); + + match login_with_chatgpt( + config.codex_home, + forced_chatgpt_workspace_id, + config.cli_auth_credentials_store_mode, + ) + .await + { Ok(_) => { eprintln!("Successfully logged in"); std::process::exit(0); @@ -46,7 +70,16 @@ pub async fn run_login_with_api_key( ) -> ! { let config = load_config_or_exit(cli_config_overrides).await; - match login_with_api_key(&config.codex_home, &api_key) { + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Chatgpt)) { + eprintln!("API key login is disabled. Use ChatGPT login instead."); + std::process::exit(1); + } + + match login_with_api_key( + &config.codex_home, + &api_key, + config.cli_auth_credentials_store_mode, + ) { Ok(_) => { eprintln!("Successfully logged in"); std::process::exit(0); @@ -92,9 +125,16 @@ pub async fn run_login_with_device_code( client_id: Option, ) -> ! { let config = load_config_or_exit(cli_config_overrides).await; + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { + eprintln!("ChatGPT login is disabled. Use API key login instead."); + std::process::exit(1); + } + let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); let mut opts = ServerOptions::new( config.codex_home, client_id.unwrap_or(CLIENT_ID.to_string()), + forced_chatgpt_workspace_id, + config.cli_auth_credentials_store_mode, ); if let Some(iss) = issuer_base_url { opts.issuer = iss; @@ -114,7 +154,7 @@ pub async fn run_login_with_device_code( pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; - match CodexAuth::from_codex_home(&config.codex_home) { + match CodexAuth::from_auth_storage(&config.codex_home, config.cli_auth_credentials_store_mode) { Ok(Some(auth)) => match auth.mode { AuthMode::ApiKey => match auth.get_token().await { Ok(api_key) => { @@ -145,7 +185,7 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; - match logout(&config.codex_home) { + match logout(&config.codex_home, config.cli_auth_credentials_store_mode) { Ok(true) => { eprintln!("Successfully logged out"); std::process::exit(0); diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 71fee4fd6b..d69eb5a10c 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -7,6 +7,7 @@ use codex_chatgpt::apply_command::ApplyCommand; use codex_chatgpt::apply_command::run_apply_command; use codex_cli::LandlockCommand; use codex_cli::SeatbeltCommand; +use codex_cli::WindowsCommand; use codex_cli::login::read_api_key_from_stdin; use codex_cli::login::run_login_status; use codex_cli::login::run_login_with_api_key; @@ -19,16 +20,19 @@ use codex_exec::Cli as ExecCli; use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; -use codex_tui::UpdateAction; +use codex_tui::updates::UpdateAction; use owo_colors::OwoColorize; use std::path::PathBuf; use supports_color::Stream; mod mcp_cmd; +mod wsl_paths; use crate::mcp_cmd::McpCli; +use crate::wsl_paths::normalize_for_wsl; use codex_core::config::Config; use codex_core::config::ConfigOverrides; +use codex_core::features::is_known_feature_key; /// Codex CLI /// @@ -42,7 +46,8 @@ use codex_core::config::ConfigOverrides; // The executable is sometimes invoked via a platform‑specific name like // `codex-x86_64-unknown-linux-musl`, but the help output should always use // the generic `codex` command name that users run. - bin_name = "codex" + bin_name = "codex", + override_usage = "codex [OPTIONS] [PROMPT]\n codex [OPTIONS] [ARGS]" )] struct MultitoolCli { #[clap(flatten)] @@ -104,6 +109,10 @@ enum Subcommand { #[clap(hide = true)] ResponsesApiProxy(ResponsesApiProxyArgs), + /// Internal: relay stdio to a Unix domain socket. + #[clap(hide = true, name = "stdio-to-uds")] + StdioToUds(StdioToUdsCommand), + /// Inspect feature flags. Features(FeaturesCli), } @@ -145,6 +154,9 @@ enum SandboxCommand { /// Run a command under Landlock+seccomp (Linux only). #[clap(visible_alias = "landlock")] Linux(LandlockCommand), + + /// Run a command under Windows restricted token (Windows only). + Windows(WindowsCommand), } #[derive(Debug, Parser)] @@ -205,6 +217,13 @@ struct GenerateTsCommand { prettier: Option, } +#[derive(Debug, Parser)] +struct StdioToUdsCommand { + /// Path to the Unix domain socket to connect to. + #[arg(value_name = "SOCKET_PATH")] + socket_path: PathBuf, +} + fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec { let AppExitInfo { token_usage, @@ -253,7 +272,11 @@ fn run_update_action(action: UpdateAction) -> anyhow::Result<()> { let (cmd, args) = action.command_args(); let cmd_str = action.command_str(); println!("Updating Codex via `{cmd_str}`..."); - let status = std::process::Command::new(cmd).args(args).status()?; + let command_path = normalize_for_wsl(cmd); + let normalized_args: Vec = args.iter().map(normalize_for_wsl).collect(); + let status = std::process::Command::new(&command_path) + .args(&normalized_args) + .status()?; if !status.success() { anyhow::bail!("`{cmd_str}` failed with status {status}"); } @@ -274,15 +297,25 @@ struct FeatureToggles { } impl FeatureToggles { - fn to_overrides(&self) -> Vec { + fn to_overrides(&self) -> anyhow::Result> { let mut v = Vec::new(); - for k in &self.enable { - v.push(format!("features.{k}=true")); + for feature in &self.enable { + Self::validate_feature(feature)?; + v.push(format!("features.{feature}=true")); } - for k in &self.disable { - v.push(format!("features.{k}=false")); + for feature in &self.disable { + Self::validate_feature(feature)?; + v.push(format!("features.{feature}=false")); + } + Ok(v) + } + + fn validate_feature(feature: &str) -> anyhow::Result<()> { + if is_known_feature_key(feature) { + Ok(()) + } else { + anyhow::bail!("Unknown feature flag: {feature}") } - v } } @@ -333,9 +366,8 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() } = MultitoolCli::parse(); // Fold --enable/--disable into config overrides so they flow to all subcommands. - root_config_overrides - .raw_overrides - .extend(feature_toggles.to_overrides()); + let toggle_overrides = feature_toggles.to_overrides()?; + root_config_overrides.raw_overrides.extend(toggle_overrides); match subcommand { None => { @@ -450,6 +482,17 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() ) .await?; } + SandboxCommand::Windows(mut windows_cli) => { + prepend_config_flags( + &mut windows_cli.config_overrides, + root_config_overrides.clone(), + ); + codex_cli::debug_sandbox::run_command_under_windows( + windows_cli, + codex_linux_sandbox_exe, + ) + .await?; + } }, Some(Subcommand::Apply(mut apply_cli)) => { prepend_config_flags( @@ -462,21 +505,32 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) .await??; } + Some(Subcommand::StdioToUds(cmd)) => { + let socket_path = cmd.socket_path; + tokio::task::spawn_blocking(move || codex_stdio_to_uds::run(socket_path.as_path())) + .await??; + } Some(Subcommand::GenerateTs(gen_cli)) => { codex_protocol_ts::generate_ts(&gen_cli.out_dir, gen_cli.prettier.as_deref())?; } Some(Subcommand::Features(FeaturesCli { sub })) => match sub { FeaturesSubcommand::List => { // Respect root-level `-c` overrides plus top-level flags like `--profile`. - let cli_kv_overrides = root_config_overrides + let mut cli_kv_overrides = root_config_overrides .parse_overrides() - .map_err(|e| anyhow::anyhow!(e))?; + .map_err(anyhow::Error::msg)?; + + // Honor `--search` via the new feature toggle. + if interactive.web_search { + cli_kv_overrides.push(( + "features.web_search_request".to_string(), + toml::Value::Boolean(true), + )); + } // Thread through relevant top-level flags (at minimum, `--profile`). - // Also honor `--search` since it maps to a feature toggle. let overrides = ConfigOverrides { config_profile: interactive.config_profile.clone(), - tools_web_search_request: interactive.web_search.then_some(true), ..Default::default() }; @@ -563,6 +617,9 @@ fn merge_resume_cli_flags(interactive: &mut TuiCli, resume_cli: TuiCli) { if !resume_cli.images.is_empty() { interactive.images = resume_cli.images; } + if !resume_cli.add_dir.is_empty() { + interactive.add_dir.extend(resume_cli.add_dir); + } if let Some(prompt) = resume_cli.prompt { interactive.prompt = Some(prompt); } @@ -585,6 +642,7 @@ mod tests { use assert_matches::assert_matches; use codex_core::protocol::TokenUsage; use codex_protocol::ConversationId; + use pretty_assertions::assert_eq; fn finalize_from_args(args: &[&str]) -> TuiCli { let cli = MultitoolCli::try_parse_from(args).expect("parse"); @@ -761,4 +819,32 @@ mod tests { assert!(!interactive.resume_last); assert_eq!(interactive.resume_session_id, None); } + + #[test] + fn feature_toggles_known_features_generate_overrides() { + let toggles = FeatureToggles { + enable: vec!["web_search_request".to_string()], + disable: vec!["unified_exec".to_string()], + }; + let overrides = toggles.to_overrides().expect("valid features"); + assert_eq!( + overrides, + vec![ + "features.web_search_request=true".to_string(), + "features.unified_exec=false".to_string(), + ] + ); + } + + #[test] + fn feature_toggles_unknown_feature_errors() { + let toggles = FeatureToggles { + enable: vec!["does_not_exist".to_string()], + disable: Vec::new(), + }; + let err = toggles + .to_overrides() + .expect_err("feature should be rejected"); + assert_eq!(err.to_string(), "Unknown feature flag: does_not_exist"); + } } diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 888a3092a5..ec37c3a6bd 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -6,13 +6,14 @@ use anyhow::anyhow; use anyhow::bail; use clap::ArgGroup; use codex_common::CliConfigOverrides; +use codex_common::format_env_display::format_env_display; use codex_core::config::Config; use codex_core::config::ConfigOverrides; +use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config::find_codex_home; use codex_core::config::load_global_mcp_servers; -use codex_core::config::write_global_mcp_servers; -use codex_core::config_types::McpServerConfig; -use codex_core::config_types::McpServerTransportConfig; +use codex_core::config::types::McpServerConfig; +use codex_core::config::types::McpServerTransportConfig; use codex_core::features::Feature; use codex_core::mcp::auth::compute_auth_statuses; use codex_core::protocol::McpAuthStatus; @@ -149,6 +150,10 @@ pub struct RemoveArgs { pub struct LoginArgs { /// Name of the MCP server to authenticate with oauth. pub name: String, + + /// Comma-separated list of OAuth scopes to request. + #[arg(long, value_delimiter = ',', value_name = "SCOPE,SCOPE")] + pub scopes: Vec, } #[derive(Debug, clap::Parser)] @@ -191,7 +196,9 @@ impl McpCli { async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Result<()> { // Validate any provided overrides even though they are not currently applied. - let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) .await .context("failed to load configuration")?; @@ -227,6 +234,8 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re command: command_bin, args: command_args, env: env_map, + env_vars: Vec::new(), + cwd: None, } } AddMcpTransportArgs { @@ -239,6 +248,8 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re } => McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers: None, + env_http_headers: None, }, AddMcpTransportArgs { .. } => bail!("exactly one of --command or --url must be provided"), }; @@ -248,11 +259,16 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re enabled: true, startup_timeout_sec: None, tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, }; servers.insert(name.clone(), new_entry); - write_global_mcp_servers(&codex_home, &servers) + ConfigEditsBuilder::new(&codex_home) + .replace_mcp_servers(&servers) + .apply() + .await .with_context(|| format!("failed to write MCP servers to {}", codex_home.display()))?; println!("Added global MCP server '{name}'."); @@ -260,19 +276,45 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re if let McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var: None, + http_headers, + env_http_headers, } = transport - && matches!(supports_oauth_login(&url).await, Ok(true)) { - println!("Detected OAuth support. Starting OAuth flow…"); - perform_oauth_login(&name, &url, config.mcp_oauth_credentials_store_mode).await?; - println!("Successfully logged in."); + match supports_oauth_login(&url).await { + Ok(true) => { + if !config.features.enabled(Feature::RmcpClient) { + println!( + "MCP server supports login. Add `experimental_use_rmcp_client = true` \ + to your config.toml and run `codex mcp login {name}` to login." + ); + } else { + println!("Detected OAuth support. Starting OAuth flow…"); + perform_oauth_login( + &name, + &url, + config.mcp_oauth_credentials_store_mode, + http_headers.clone(), + env_http_headers.clone(), + &Vec::new(), + ) + .await?; + println!("Successfully logged in."); + } + } + Ok(false) => {} + Err(_) => println!( + "MCP server may or may not require login. Run `codex mcp login {name}` to login." + ), + } } Ok(()) } async fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveArgs) -> Result<()> { - config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; + config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; let RemoveArgs { name } = remove_args; @@ -286,7 +328,10 @@ async fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveAr let removed = servers.remove(&name).is_some(); if removed { - write_global_mcp_servers(&codex_home, &servers) + ConfigEditsBuilder::new(&codex_home) + .replace_mcp_servers(&servers) + .apply() + .await .with_context(|| format!("failed to write MCP servers to {}", codex_home.display()))?; } @@ -300,35 +345,52 @@ async fn run_remove(config_overrides: &CliConfigOverrides, remove_args: RemoveAr } async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs) -> Result<()> { - let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) .await .context("failed to load configuration")?; if !config.features.enabled(Feature::RmcpClient) { bail!( - "OAuth login is only supported when experimental_use_rmcp_client is true in config.toml." + "OAuth login is only supported when [features].rmcp_client is true in config.toml. See https://github.com/openai/codex/blob/main/docs/config.md#feature-flags for details." ); } - let LoginArgs { name } = login_args; + let LoginArgs { name, scopes } = login_args; let Some(server) = config.mcp_servers.get(&name) else { bail!("No MCP server named '{name}' found."); }; - let url = match &server.transport { - McpServerTransportConfig::StreamableHttp { url, .. } => url.clone(), + let (url, http_headers, env_http_headers) = match &server.transport { + McpServerTransportConfig::StreamableHttp { + url, + http_headers, + env_http_headers, + .. + } => (url.clone(), http_headers.clone(), env_http_headers.clone()), _ => bail!("OAuth login is only supported for streamable HTTP servers."), }; - perform_oauth_login(&name, &url, config.mcp_oauth_credentials_store_mode).await?; + perform_oauth_login( + &name, + &url, + config.mcp_oauth_credentials_store_mode, + http_headers, + env_http_headers, + &scopes, + ) + .await?; println!("Successfully logged in to MCP server '{name}'."); Ok(()) } async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutArgs) -> Result<()> { - let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) .await .context("failed to load configuration")?; @@ -355,7 +417,9 @@ async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutAr } async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> Result<()> { - let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) .await .context("failed to load configuration")?; @@ -374,23 +438,35 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> .map(|(name, cfg)| { let auth_status = auth_statuses .get(name.as_str()) - .copied() + .map(|entry| entry.auth_status) .unwrap_or(McpAuthStatus::Unsupported); let transport = match &cfg.transport { - McpServerTransportConfig::Stdio { command, args, env } => serde_json::json!({ + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => serde_json::json!({ "type": "stdio", "command": command, "args": args, "env": env, + "env_vars": env_vars, + "cwd": cwd, }), McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { serde_json::json!({ "type": "streamable_http", "url": url, "bearer_token_env_var": bearer_token_env_var, + "http_headers": http_headers, + "env_http_headers": env_http_headers, }) } }; @@ -419,30 +495,29 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> return Ok(()); } - let mut stdio_rows: Vec<[String; 6]> = Vec::new(); + let mut stdio_rows: Vec<[String; 7]> = Vec::new(); let mut http_rows: Vec<[String; 5]> = Vec::new(); for (name, cfg) in entries { match &cfg.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { let args_display = if args.is_empty() { "-".to_string() } else { args.join(" ") }; - let env_display = match env.as_ref() { - None => "-".to_string(), - Some(map) if map.is_empty() => "-".to_string(), - Some(map) => { - let mut pairs: Vec<_> = map.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); - pairs - .into_iter() - .map(|(k, v)| format!("{k}={v}")) - .collect::>() - .join(", ") - } - }; + let env_display = format_env_display(env.as_ref(), env_vars); + let cwd_display = cwd + .as_ref() + .map(|path| path.display().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "-".to_string()); let status = if cfg.enabled { "enabled".to_string() } else { @@ -450,7 +525,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> }; let auth_status = auth_statuses .get(name.as_str()) - .copied() + .map(|entry| entry.auth_status) .unwrap_or(McpAuthStatus::Unsupported) .to_string(); stdio_rows.push([ @@ -458,6 +533,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> command.clone(), args_display, env_display, + cwd_display, status, auth_status, ]); @@ -465,6 +541,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + .. } => { let status = if cfg.enabled { "enabled".to_string() @@ -473,13 +550,15 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> }; let auth_status = auth_statuses .get(name.as_str()) - .copied() + .map(|entry| entry.auth_status) .unwrap_or(McpAuthStatus::Unsupported) .to_string(); + let bearer_token_display = + bearer_token_env_var.as_deref().unwrap_or("-").to_string(); http_rows.push([ name.clone(), url.clone(), - bearer_token_env_var.clone().unwrap_or("-".to_string()), + bearer_token_display, status, auth_status, ]); @@ -493,6 +572,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> "Command".len(), "Args".len(), "Env".len(), + "Cwd".len(), "Status".len(), "Auth".len(), ]; @@ -503,36 +583,40 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> } println!( - "{name: } async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Result<()> { - let overrides = config_overrides.parse_overrides().map_err(|e| anyhow!(e))?; + let overrides = config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; let config = Config::load_with_cli_overrides(overrides, ConfigOverrides::default()) .await .context("failed to load configuration")?; @@ -601,25 +687,39 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re if get_args.json { let transport = match &server.transport { - McpServerTransportConfig::Stdio { command, args, env } => serde_json::json!({ + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => serde_json::json!({ "type": "stdio", "command": command, "args": args, "env": env, + "env_vars": env_vars, + "cwd": cwd, }), McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => serde_json::json!({ "type": "streamable_http", "url": url, "bearer_token_env_var": bearer_token_env_var, + "http_headers": http_headers, + "env_http_headers": env_http_headers, }), }; let output = serde_json::to_string_pretty(&serde_json::json!({ "name": get_args.name, "enabled": server.enabled, "transport": transport, + "enabled_tools": server.enabled_tools.clone(), + "disabled_tools": server.disabled_tools.clone(), "startup_timeout_sec": server .startup_timeout_sec .map(|timeout| timeout.as_secs_f64()), @@ -631,10 +731,36 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re return Ok(()); } + if !server.enabled { + println!("{} (disabled)", get_args.name); + return Ok(()); + } + println!("{}", get_args.name); println!(" enabled: {}", server.enabled); + let format_tool_list = |tools: &Option>| -> String { + match tools { + Some(list) if list.is_empty() => "[]".to_string(), + Some(list) => list.join(", "), + None => "-".to_string(), + } + }; + if server.enabled_tools.is_some() { + let enabled_tools_display = format_tool_list(&server.enabled_tools); + println!(" enabled_tools: {enabled_tools_display}"); + } + if server.disabled_tools.is_some() { + let disabled_tools_display = format_tool_list(&server.disabled_tools); + println!(" disabled_tools: {disabled_tools_display}"); + } match &server.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { println!(" transport: stdio"); println!(" command: {command}"); let args_display = if args.is_empty() { @@ -643,29 +769,51 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re args.join(" ") }; println!(" args: {args_display}"); - let env_display = match env.as_ref() { - None => "-".to_string(), - Some(map) if map.is_empty() => "-".to_string(), - Some(map) => { - let mut pairs: Vec<_> = map.iter().collect(); - pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); - pairs - .into_iter() - .map(|(k, v)| format!("{k}={v}")) - .collect::>() - .join(", ") - } - }; + let cwd_display = cwd + .as_ref() + .map(|path| path.display().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "-".to_string()); + println!(" cwd: {cwd_display}"); + let env_display = format_env_display(env.as_ref(), env_vars); println!(" env: {env_display}"); } McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { println!(" transport: streamable_http"); println!(" url: {url}"); - let env_var = bearer_token_env_var.as_deref().unwrap_or("-"); - println!(" bearer_token_env_var: {env_var}"); + let bearer_token_display = bearer_token_env_var.as_deref().unwrap_or("-"); + println!(" bearer_token_env_var: {bearer_token_display}"); + let headers_display = match http_headers { + Some(map) if !map.is_empty() => { + let mut pairs: Vec<_> = map.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs + .into_iter() + .map(|(k, _)| format!("{k}=*****")) + .collect::>() + .join(", ") + } + _ => "-".to_string(), + }; + println!(" http_headers: {headers_display}"); + let env_headers_display = match env_http_headers { + Some(map) if !map.is_empty() => { + let mut pairs: Vec<_> = map.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + pairs + .into_iter() + .map(|(k, var)| format!("{k}={var}")) + .collect::>() + .join(", ") + } + _ => "-".to_string(), + }; + println!(" env_http_headers: {env_headers_display}"); } } if let Some(timeout) = server.startup_timeout_sec { diff --git a/codex-rs/cli/src/wsl_paths.rs b/codex-rs/cli/src/wsl_paths.rs new file mode 100644 index 0000000000..56ce8668cf --- /dev/null +++ b/codex-rs/cli/src/wsl_paths.rs @@ -0,0 +1,76 @@ +use std::ffi::OsStr; + +/// WSL-specific path helpers used by the updater logic. +/// +/// See https://github.com/openai/codex/issues/6086. +pub fn is_wsl() -> bool { + #[cfg(target_os = "linux")] + { + if std::env::var_os("WSL_DISTRO_NAME").is_some() { + return true; + } + match std::fs::read_to_string("/proc/version") { + Ok(version) => version.to_lowercase().contains("microsoft"), + Err(_) => false, + } + } + #[cfg(not(target_os = "linux"))] + { + false + } +} + +/// Convert a Windows absolute path (`C:\foo\bar` or `C:/foo/bar`) to a WSL mount path (`/mnt/c/foo/bar`). +/// Returns `None` if the input does not look like a Windows drive path. +pub fn win_path_to_wsl(path: &str) -> Option { + let bytes = path.as_bytes(); + if bytes.len() < 3 + || bytes[1] != b':' + || !(bytes[2] == b'\\' || bytes[2] == b'/') + || !bytes[0].is_ascii_alphabetic() + { + return None; + } + let drive = (bytes[0] as char).to_ascii_lowercase(); + let tail = path[3..].replace('\\', "/"); + if tail.is_empty() { + return Some(format!("/mnt/{drive}")); + } + Some(format!("/mnt/{drive}/{tail}")) +} + +/// If under WSL and given a Windows-style path, return the equivalent `/mnt//…` path. +/// Otherwise returns the input unchanged. +pub fn normalize_for_wsl>(path: P) -> String { + let value = path.as_ref().to_string_lossy().to_string(); + if !is_wsl() { + return value; + } + if let Some(mapped) = win_path_to_wsl(&value) { + return mapped; + } + value +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn win_to_wsl_basic() { + assert_eq!( + win_path_to_wsl(r"C:\Temp\codex.zip").as_deref(), + Some("/mnt/c/Temp/codex.zip") + ); + assert_eq!( + win_path_to_wsl("D:/Work/codex.tgz").as_deref(), + Some("/mnt/d/Work/codex.tgz") + ); + assert!(win_path_to_wsl("/home/user/codex").is_none()); + } + + #[test] + fn normalize_is_noop_on_unix_paths() { + assert_eq!(normalize_for_wsl("/home/u/x"), "/home/u/x"); + } +} diff --git a/codex-rs/cli/tests/mcp_add_remove.rs b/codex-rs/cli/tests/mcp_add_remove.rs index 705509abf5..2911637331 100644 --- a/codex-rs/cli/tests/mcp_add_remove.rs +++ b/codex-rs/cli/tests/mcp_add_remove.rs @@ -2,7 +2,7 @@ use std::path::Path; use anyhow::Result; use codex_core::config::load_global_mcp_servers; -use codex_core::config_types::McpServerTransportConfig; +use codex_core::config::types::McpServerTransportConfig; use predicates::str::contains; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -28,10 +28,18 @@ async fn add_and_remove_server_updates_global_config() -> Result<()> { assert_eq!(servers.len(), 1); let docs = servers.get("docs").expect("server should exist"); match &docs.transport { - McpServerTransportConfig::Stdio { command, args, env } => { + McpServerTransportConfig::Stdio { + command, + args, + env, + env_vars, + cwd, + } => { assert_eq!(command, "echo"); assert_eq!(args, &vec!["hello".to_string()]); assert!(env.is_none()); + assert!(env_vars.is_empty()); + assert!(cwd.is_none()); } other => panic!("unexpected transport: {other:?}"), } @@ -112,9 +120,13 @@ async fn add_streamable_http_without_manual_token() -> Result<()> { McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { assert_eq!(url, "https://example.com/mcp"); assert!(bearer_token_env_var.is_none()); + assert!(http_headers.is_none()); + assert!(env_http_headers.is_none()); } other => panic!("unexpected transport: {other:?}"), } @@ -150,9 +162,13 @@ async fn add_streamable_http_with_custom_env_var() -> Result<()> { McpServerTransportConfig::StreamableHttp { url, bearer_token_env_var, + http_headers, + env_http_headers, } => { assert_eq!(url, "https://example.com/issues"); assert_eq!(bearer_token_env_var.as_deref(), Some("GITHUB_TOKEN")); + assert!(http_headers.is_none()); + assert!(env_http_headers.is_none()); } other => panic!("unexpected transport: {other:?}"), } diff --git a/codex-rs/cli/tests/mcp_list.rs b/codex-rs/cli/tests/mcp_list.rs index 8f33a8e470..1492365afb 100644 --- a/codex-rs/cli/tests/mcp_list.rs +++ b/codex-rs/cli/tests/mcp_list.rs @@ -1,6 +1,9 @@ use std::path::Path; use anyhow::Result; +use codex_core::config::edit::ConfigEditsBuilder; +use codex_core::config::load_global_mcp_servers; +use codex_core::config::types::McpServerTransportConfig; use predicates::prelude::PredicateBooleanExt; use predicates::str::contains; use pretty_assertions::assert_eq; @@ -27,8 +30,8 @@ fn list_shows_empty_state() -> Result<()> { Ok(()) } -#[test] -fn list_and_get_render_expected_output() -> Result<()> { +#[tokio::test] +async fn list_and_get_render_expected_output() -> Result<()> { let codex_home = TempDir::new()?; let mut add = codex_command(codex_home.path())?; @@ -46,6 +49,20 @@ fn list_and_get_render_expected_output() -> Result<()> { .assert() .success(); + let mut servers = load_global_mcp_servers(codex_home.path()).await?; + let docs_entry = servers + .get_mut("docs") + .expect("docs server should exist after add"); + match &mut docs_entry.transport { + McpServerTransportConfig::Stdio { env_vars, .. } => { + *env_vars = vec!["APP_TOKEN".to_string(), "WORKSPACE_ID".to_string()]; + } + other => panic!("unexpected transport: {other:?}"), + } + ConfigEditsBuilder::new(codex_home.path()) + .replace_mcp_servers(&servers) + .apply_blocking()?; + let mut list_cmd = codex_command(codex_home.path())?; let list_output = list_cmd.args(["mcp", "list"]).output()?; assert!(list_output.status.success()); @@ -53,7 +70,9 @@ fn list_and_get_render_expected_output() -> Result<()> { assert!(stdout.contains("Name")); assert!(stdout.contains("docs")); assert!(stdout.contains("docs-server")); - assert!(stdout.contains("TOKEN=secret")); + assert!(stdout.contains("TOKEN=*****")); + assert!(stdout.contains("APP_TOKEN=*****")); + assert!(stdout.contains("WORKSPACE_ID=*****")); assert!(stdout.contains("Status")); assert!(stdout.contains("Auth")); assert!(stdout.contains("enabled")); @@ -79,7 +98,12 @@ fn list_and_get_render_expected_output() -> Result<()> { ], "env": { "TOKEN": "secret" - } + }, + "env_vars": [ + "APP_TOKEN", + "WORKSPACE_ID" + ], + "cwd": null }, "startup_timeout_sec": null, "tool_timeout_sec": null, @@ -97,7 +121,9 @@ fn list_and_get_render_expected_output() -> Result<()> { assert!(stdout.contains("transport: stdio")); assert!(stdout.contains("command: docs-server")); assert!(stdout.contains("args: --port 4000")); - assert!(stdout.contains("env: TOKEN=secret")); + assert!(stdout.contains("env: TOKEN=*****")); + assert!(stdout.contains("APP_TOKEN=*****")); + assert!(stdout.contains("WORKSPACE_ID=*****")); assert!(stdout.contains("enabled: true")); assert!(stdout.contains("remove: codex mcp remove docs")); @@ -110,3 +136,30 @@ fn list_and_get_render_expected_output() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn get_disabled_server_shows_single_line() -> Result<()> { + let codex_home = TempDir::new()?; + + let mut add = codex_command(codex_home.path())?; + add.args(["mcp", "add", "docs", "--", "docs-server"]) + .assert() + .success(); + + let mut servers = load_global_mcp_servers(codex_home.path()).await?; + let docs = servers + .get_mut("docs") + .expect("docs server should exist after add"); + docs.enabled = false; + ConfigEditsBuilder::new(codex_home.path()) + .replace_mcp_servers(&servers) + .apply_blocking()?; + + let mut get_cmd = codex_command(codex_home.path())?; + let get_output = get_cmd.args(["mcp", "get", "docs"]).output()?; + assert!(get_output.status.success()); + let stdout = String::from_utf8(get_output.stdout)?; + assert_eq!(stdout.trim_end(), "docs (disabled)"); + + Ok(()) +} diff --git a/codex-rs/clippy.toml b/codex-rs/clippy.toml index 5a6ff7f052..a2aae6591b 100644 --- a/codex-rs/clippy.toml +++ b/codex-rs/clippy.toml @@ -7,3 +7,7 @@ disallowed-methods = [ { path = "ratatui::style::Stylize::black", reason = "Avoid hardcoding black; prefer default fg or dim/bold. Exception: Disable this rule if rendering over a hardcoded ANSI background." }, { path = "ratatui::style::Stylize::yellow", reason = "Avoid yellow; prefer other colors in `tui/styles.md`." }, ] + +# Increase the size threshold for result_large_err to accommodate +# richer error variants. +large-error-threshold = 256 diff --git a/codex-rs/cloud-tasks-client/Cargo.toml b/codex-rs/cloud-tasks-client/Cargo.toml index ca45b6e15b..1a4eaa7aa8 100644 --- a/codex-rs/cloud-tasks-client/Cargo.toml +++ b/codex-rs/cloud-tasks-client/Cargo.toml @@ -22,6 +22,6 @@ chrono = { version = "0.4", features = ["serde"] } diffy = "0.4.2" serde = { version = "1", features = ["derive"] } serde_json = "1" -thiserror = "2.0.12" +thiserror = "2.0.17" codex-backend-client = { path = "../backend-client", optional = true } -codex-git-apply = { path = "../git-apply" } +codex-git = { workspace = true } diff --git a/codex-rs/cloud-tasks-client/src/http.rs b/codex-rs/cloud-tasks-client/src/http.rs index 912681cd3b..57d39b7bda 100644 --- a/codex-rs/cloud-tasks-client/src/http.rs +++ b/codex-rs/cloud-tasks-client/src/http.rs @@ -362,13 +362,13 @@ mod api { }); } - let req = codex_git_apply::ApplyGitRequest { + let req = codex_git::ApplyGitRequest { cwd: std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir()), diff: diff.clone(), revert: false, preflight, }; - let r = codex_git_apply::apply_git_patch(&req) + let r = codex_git::apply_git_patch(&req) .map_err(|e| CloudTaskError::Io(format!("git apply failed to run: {e}")))?; let status = if r.exit_code == 0 { diff --git a/codex-rs/cloud-tasks-client/src/lib.rs b/codex-rs/cloud-tasks-client/src/lib.rs index 1c5a01e2aa..a723512f8a 100644 --- a/codex-rs/cloud-tasks-client/src/lib.rs +++ b/codex-rs/cloud-tasks-client/src/lib.rs @@ -26,4 +26,4 @@ pub use mock::MockClient; #[cfg(feature = "online")] pub use http::HttpClient; -// Reusable apply engine now lives in the shared crate `codex-git-apply`. +// Reusable apply engine now lives in the shared crate `codex-git`. diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index 6087cbea5b..8f8b3066c9 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -58,7 +58,16 @@ async fn init_backend(user_agent_suffix: &str) -> anyhow::Result let auth = match codex_core::config::find_codex_home() .ok() - .map(|home| codex_login::AuthManager::new(home, false)) + .map(|home| { + let store_mode = codex_core::config::Config::load_from_base_config_with_overrides( + codex_core::config::ConfigToml::default(), + codex_core::config::ConfigOverrides::default(), + home.clone(), + ) + .map(|cfg| cfg.cli_auth_credentials_store_mode) + .unwrap_or_default(); + codex_login::AuthManager::new(home, false, store_mode) + }) .and_then(|am| am.auth()) { Some(auth) => auth, @@ -1035,7 +1044,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an // Close task modal/pending apply if present before opening env modal app.diff_overlay = None; app.env_modal = Some(app::EnvModalState { query: String::new(), selected: 0 }); - // Cache environments until user explicitly refreshes with 'r' inside the modal. + // Cache environments while the modal is open to avoid repeated fetches. let should_fetch = app.environments.is_empty(); if should_fetch { app.env_loading = true; @@ -1086,7 +1095,19 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an let backend = Arc::clone(&backend); let best_of_n = page.best_of_n; tokio::spawn(async move { - let result = codex_cloud_tasks_client::CloudBackend::create_task(&*backend, &env, &text, "main", false, best_of_n).await; + let git_ref = if let Ok(cwd) = std::env::current_dir() { + if let Some(branch) = codex_core::git_info::default_branch_name(&cwd).await { + branch + } else if let Some(branch) = codex_core::git_info::current_branch_name(&cwd).await { + branch + } else { + "main".to_string() + } + } else { + "main".to_string() + }; + + let result = codex_cloud_tasks_client::CloudBackend::create_task(&*backend, &env, &text, &git_ref, false, best_of_n).await; let evt = match result { Ok(ok) => app::AppEvent::NewTaskSubmitted(Ok(ok)), Err(e) => app::AppEvent::NewTaskSubmitted(Err(format!("{e}"))), @@ -1094,7 +1115,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an let _ = tx.send(evt); }); } else { - app.status = "No environment selected (press 'e' to choose)".to_string(); + app.status = "No environment selected".to_string(); } } needs_redraw = true; @@ -1292,18 +1313,6 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an // Environment modal key handling match key.code { KeyCode::Esc => { app.env_modal = None; needs_redraw = true; } - KeyCode::Char('r') | KeyCode::Char('R') => { - // Trigger refresh of environments - app.env_loading = true; app.env_error = None; needs_redraw = true; - let _ = frame_tx.send(Instant::now() + Duration::from_millis(100)); - let tx = tx.clone(); - tokio::spawn(async move { - let base_url = crate::util::normalize_base_url(&std::env::var("CODEX_CLOUD_TASKS_BASE_URL").unwrap_or_else(|_| "https://chatgpt.com/backend-api".to_string())); - let headers = crate::util::build_chatgpt_headers().await; - let res = crate::env_detect::list_environments(&base_url, &headers).await; - let _ = tx.send(app::AppEvent::EnvironmentsLoaded(res)); - }); - } KeyCode::Char(ch) if !key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT) => { if let Some(m) = app.env_modal.as_mut() { m.query.push(ch); } needs_redraw = true; @@ -1410,7 +1419,7 @@ pub async fn run_main(cli: Cli, _codex_linux_sandbox_exe: Option) -> an } KeyCode::Char('o') | KeyCode::Char('O') => { app.env_modal = Some(app::EnvModalState { query: String::new(), selected: 0 }); - // Cache environments until user explicitly refreshes with 'r' inside the modal. + // Cache environments while the modal is open to avoid repeated fetches. let should_fetch = app.environments.is_empty(); if should_fetch { app.env_loading = true; app.env_error = None; } needs_redraw = true; diff --git a/codex-rs/cloud-tasks/src/ui.rs b/codex-rs/cloud-tasks/src/ui.rs index 81353a032b..e3a97aeb3f 100644 --- a/codex-rs/cloud-tasks/src/ui.rs +++ b/codex-rs/cloud-tasks/src/ui.rs @@ -945,9 +945,7 @@ pub fn draw_env_modal(frame: &mut Frame, area: Rect, app: &mut App) { // Subheader with usage hints (dim cyan) let subheader = Paragraph::new(Line::from( - "Type to search, Enter select, Esc cancel; r refresh" - .cyan() - .dim(), + "Type to search, Enter select, Esc cancel".cyan().dim(), )) .wrap(Wrap { trim: true }); frame.render_widget(subheader, rows[0]); diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index 5d160e54fa..ff17265f3d 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -70,7 +70,14 @@ pub async fn build_chatgpt_headers() -> HeaderMap { HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")), ); if let Ok(home) = codex_core::config::find_codex_home() { - let am = codex_login::AuthManager::new(home, false); + let store_mode = codex_core::config::Config::load_from_base_config_with_overrides( + codex_core::config::ConfigToml::default(), + codex_core::config::ConfigOverrides::default(), + home.clone(), + ) + .map(|cfg| cfg.cli_auth_credentials_store_mode) + .unwrap_or_default(); + let am = codex_login::AuthManager::new(home, false, store_mode); if let Some(auth) = am.auth() && let Ok(tok) = auth.get_token().await && !tok.is_empty() diff --git a/codex-rs/codex-backend-openapi-models/Cargo.toml b/codex-rs/codex-backend-openapi-models/Cargo.toml index 811ee72d8f..1a600495b9 100644 --- a/codex-rs/codex-backend-openapi-models/Cargo.toml +++ b/codex-rs/codex-backend-openapi-models/Cargo.toml @@ -15,3 +15,7 @@ path = "src/lib.rs" [dependencies] serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_with = "3" + +[package.metadata.cargo-shear] +ignored = ["serde_with"] diff --git a/codex-rs/codex-backend-openapi-models/src/models/mod.rs b/codex-rs/codex-backend-openapi-models/src/models/mod.rs index e2cb972f10..96348d72c2 100644 --- a/codex-rs/codex-backend-openapi-models/src/models/mod.rs +++ b/codex-rs/codex-backend-openapi-models/src/models/mod.rs @@ -3,6 +3,7 @@ // Currently export only the types referenced by the workspace // The process for this will change +// Cloud Tasks pub mod code_task_details_response; pub use self::code_task_details_response::CodeTaskDetailsResponse; @@ -20,3 +21,14 @@ pub use self::task_list_item::TaskListItem; pub mod paginated_list_task_list_item_; pub use self::paginated_list_task_list_item_::PaginatedListTaskListItem; + +// Rate Limits +pub mod rate_limit_status_payload; +pub use self::rate_limit_status_payload::PlanType; +pub use self::rate_limit_status_payload::RateLimitStatusPayload; + +pub mod rate_limit_status_details; +pub use self::rate_limit_status_details::RateLimitStatusDetails; + +pub mod rate_limit_window_snapshot; +pub use self::rate_limit_window_snapshot::RateLimitWindowSnapshot; diff --git a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_details.rs b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_details.rs new file mode 100644 index 0000000000..ca9fdfe240 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_details.rs @@ -0,0 +1,46 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct RateLimitStatusDetails { + #[serde(rename = "allowed")] + pub allowed: bool, + #[serde(rename = "limit_reached")] + pub limit_reached: bool, + #[serde( + rename = "primary_window", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub primary_window: Option>>, + #[serde( + rename = "secondary_window", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub secondary_window: Option>>, +} + +impl RateLimitStatusDetails { + pub fn new(allowed: bool, limit_reached: bool) -> RateLimitStatusDetails { + RateLimitStatusDetails { + allowed, + limit_reached, + primary_window: None, + secondary_window: None, + } + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs new file mode 100644 index 0000000000..d2af76f4d7 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs @@ -0,0 +1,65 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use crate::models; +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct RateLimitStatusPayload { + #[serde(rename = "plan_type")] + pub plan_type: PlanType, + #[serde( + rename = "rate_limit", + default, + with = "::serde_with::rust::double_option", + skip_serializing_if = "Option::is_none" + )] + pub rate_limit: Option>>, +} + +impl RateLimitStatusPayload { + pub fn new(plan_type: PlanType) -> RateLimitStatusPayload { + RateLimitStatusPayload { + plan_type, + rate_limit: None, + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)] +pub enum PlanType { + #[serde(rename = "free")] + Free, + #[serde(rename = "go")] + Go, + #[serde(rename = "plus")] + Plus, + #[serde(rename = "pro")] + Pro, + #[serde(rename = "team")] + Team, + #[serde(rename = "business")] + Business, + #[serde(rename = "education")] + Education, + #[serde(rename = "quorum")] + Quorum, + #[serde(rename = "enterprise")] + Enterprise, + #[serde(rename = "edu")] + Edu, +} + +impl Default for PlanType { + fn default() -> PlanType { + Self::Free + } +} diff --git a/codex-rs/codex-backend-openapi-models/src/models/rate_limit_window_snapshot.rs b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_window_snapshot.rs new file mode 100644 index 0000000000..4fc04f4be6 --- /dev/null +++ b/codex-rs/codex-backend-openapi-models/src/models/rate_limit_window_snapshot.rs @@ -0,0 +1,40 @@ +/* + * codex-backend + * + * codex-backend + * + * The version of the OpenAPI document: 0.0.1 + * + * Generated by: https://openapi-generator.tech + */ + +use serde::Deserialize; +use serde::Serialize; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct RateLimitWindowSnapshot { + #[serde(rename = "used_percent")] + pub used_percent: i32, + #[serde(rename = "limit_window_seconds")] + pub limit_window_seconds: i32, + #[serde(rename = "reset_after_seconds")] + pub reset_after_seconds: i32, + #[serde(rename = "reset_at")] + pub reset_at: i32, +} + +impl RateLimitWindowSnapshot { + pub fn new( + used_percent: i32, + limit_window_seconds: i32, + reset_after_seconds: i32, + reset_at: i32, + ) -> RateLimitWindowSnapshot { + RateLimitWindowSnapshot { + used_percent, + limit_window_seconds, + reset_after_seconds, + reset_at, + } + } +} diff --git a/codex-rs/common/src/approval_presets.rs b/codex-rs/common/src/approval_presets.rs index 6dc0cff0ee..6c3bf395ad 100644 --- a/codex-rs/common/src/approval_presets.rs +++ b/codex-rs/common/src/approval_presets.rs @@ -24,21 +24,21 @@ pub fn builtin_approval_presets() -> Vec { ApprovalPreset { id: "read-only", label: "Read Only", - description: "Codex can read files and answer questions. Codex requires approval to make edits, run commands, or access network", + description: "Codex can read files and answer questions. Codex requires approval to make edits, run commands, or access network.", approval: AskForApproval::OnRequest, sandbox: SandboxPolicy::ReadOnly, }, ApprovalPreset { id: "auto", label: "Auto", - description: "Codex can read files, make edits, and run commands in the workspace. Codex requires approval to work outside the workspace or access network", + description: "Codex can read files, make edits, and run commands in the workspace. Codex requires approval to work outside the workspace or access network.", approval: AskForApproval::OnRequest, sandbox: SandboxPolicy::new_workspace_write_policy(), }, ApprovalPreset { id: "full-access", label: "Full Access", - description: "Codex can read files, make edits, and run commands with network access, without approval. Exercise caution", + description: "Codex can read files, make edits, and run commands with network access, without approval. Exercise caution.", approval: AskForApproval::Never, sandbox: SandboxPolicy::DangerFullAccess, }, diff --git a/codex-rs/common/src/format_env_display.rs b/codex-rs/common/src/format_env_display.rs new file mode 100644 index 0000000000..fa35bdce25 --- /dev/null +++ b/codex-rs/common/src/format_env_display.rs @@ -0,0 +1,62 @@ +use std::collections::HashMap; + +pub fn format_env_display(env: Option<&HashMap>, env_vars: &[String]) -> String { + let mut parts: Vec = Vec::new(); + + if let Some(map) = env { + let mut pairs: Vec<_> = map.iter().collect(); + pairs.sort_by(|(a, _), (b, _)| a.cmp(b)); + parts.extend(pairs.into_iter().map(|(key, _)| format!("{key}=*****"))); + } + + if !env_vars.is_empty() { + parts.extend(env_vars.iter().map(|var| format!("{var}=*****"))); + } + + if parts.is_empty() { + "-".to_string() + } else { + parts.join(", ") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn returns_dash_when_empty() { + assert_eq!(format_env_display(None, &[]), "-"); + + let empty_map = HashMap::new(); + assert_eq!(format_env_display(Some(&empty_map), &[]), "-"); + } + + #[test] + fn formats_sorted_env_pairs() { + let mut env = HashMap::new(); + env.insert("B".to_string(), "two".to_string()); + env.insert("A".to_string(), "one".to_string()); + + assert_eq!(format_env_display(Some(&env), &[]), "A=*****, B=*****"); + } + + #[test] + fn formats_env_vars_with_dollar_prefix() { + let vars = vec!["TOKEN".to_string(), "PATH".to_string()]; + + assert_eq!(format_env_display(None, &vars), "TOKEN=*****, PATH=*****"); + } + + #[test] + fn combines_env_pairs_and_vars() { + let mut env = HashMap::new(); + env.insert("HOME".to_string(), "/tmp".to_string()); + let vars = vec!["TOKEN".to_string()]; + + assert_eq!( + format_env_display(Some(&env), &vars), + "HOME=*****, TOKEN=*****" + ); + } +} diff --git a/codex-rs/common/src/lib.rs b/codex-rs/common/src/lib.rs index 292503f77e..276bfca069 100644 --- a/codex-rs/common/src/lib.rs +++ b/codex-rs/common/src/lib.rs @@ -13,6 +13,9 @@ mod sandbox_mode_cli_arg; #[cfg(feature = "cli")] pub use sandbox_mode_cli_arg::SandboxModeCliArg; +#[cfg(feature = "cli")] +pub mod format_env_display; + #[cfg(any(feature = "cli", test))] mod config_override; diff --git a/codex-rs/common/src/model_presets.rs b/codex-rs/common/src/model_presets.rs index 9954ad1890..5f0cdf8fb8 100644 --- a/codex-rs/common/src/model_presets.rs +++ b/codex-rs/common/src/model_presets.rs @@ -1,73 +1,119 @@ use codex_app_server_protocol::AuthMode; use codex_core::protocol_config_types::ReasoningEffort; -/// A simple preset pairing a model slug with a reasoning effort. +/// A reasoning effort option that can be surfaced for a model. +#[derive(Debug, Clone, Copy)] +pub struct ReasoningEffortPreset { + /// Effort level that the model supports. + pub effort: ReasoningEffort, + /// Short human description shown next to the effort in UIs. + pub description: &'static str, +} + +/// Metadata describing a Codex-supported model. #[derive(Debug, Clone, Copy)] pub struct ModelPreset { /// Stable identifier for the preset. pub id: &'static str, - /// Display label shown in UIs. - pub label: &'static str, - /// Short human description shown next to the label in UIs. - pub description: &'static str, /// Model slug (e.g., "gpt-5"). pub model: &'static str, - /// Reasoning effort to apply for this preset. - pub effort: Option, + /// Display name shown in UIs. + pub display_name: &'static str, + /// Short human description shown in UIs. + pub description: &'static str, + /// Reasoning effort applied when none is explicitly chosen. + pub default_reasoning_effort: ReasoningEffort, + /// Supported reasoning effort options. + pub supported_reasoning_efforts: &'static [ReasoningEffortPreset], + /// Whether this is the default model for new users. + pub is_default: bool, } const PRESETS: &[ModelPreset] = &[ ModelPreset { - id: "gpt-5-codex-low", - label: "gpt-5-codex low", - description: "Fastest responses with limited reasoning", + id: "gpt-5-codex", model: "gpt-5-codex", - effort: Some(ReasoningEffort::Low), + display_name: "gpt-5-codex", + description: "Optimized for codex.", + default_reasoning_effort: ReasoningEffort::Medium, + supported_reasoning_efforts: &[ + ReasoningEffortPreset { + effort: ReasoningEffort::Low, + description: "Fastest responses with limited reasoning", + }, + ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: "Dynamically adjusts reasoning based on the task", + }, + ReasoningEffortPreset { + effort: ReasoningEffort::High, + description: "Maximizes reasoning depth for complex or ambiguous problems", + }, + ], + is_default: true, }, ModelPreset { - id: "gpt-5-codex-medium", - label: "gpt-5-codex medium", - description: "Dynamically adjusts reasoning based on the task", - model: "gpt-5-codex", - effort: Some(ReasoningEffort::Medium), + id: "gpt-5-codex-mini", + model: "gpt-5-codex-mini", + display_name: "gpt-5-codex-mini", + description: "Optimized for codex. Cheaper, faster, but less capable.", + default_reasoning_effort: ReasoningEffort::Medium, + supported_reasoning_efforts: &[ + ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: "Dynamically adjusts reasoning based on the task", + }, + ReasoningEffortPreset { + effort: ReasoningEffort::High, + description: "Maximizes reasoning depth for complex or ambiguous problems", + }, + ], + is_default: false, }, ModelPreset { - id: "gpt-5-codex-high", - label: "gpt-5-codex high", - description: "Maximizes reasoning depth for complex or ambiguous problems", - model: "gpt-5-codex", - effort: Some(ReasoningEffort::High), - }, - ModelPreset { - id: "gpt-5-minimal", - label: "gpt-5 minimal", - description: "Fastest responses with little reasoning", + id: "gpt-5", model: "gpt-5", - effort: Some(ReasoningEffort::Minimal), - }, - ModelPreset { - id: "gpt-5-low", - label: "gpt-5 low", - description: "Balances speed with some reasoning; useful for straightforward queries and short explanations", - model: "gpt-5", - effort: Some(ReasoningEffort::Low), - }, - ModelPreset { - id: "gpt-5-medium", - label: "gpt-5 medium", - description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks", - model: "gpt-5", - effort: Some(ReasoningEffort::Medium), - }, - ModelPreset { - id: "gpt-5-high", - label: "gpt-5 high", - description: "Maximizes reasoning depth for complex or ambiguous problems", - model: "gpt-5", - effort: Some(ReasoningEffort::High), + display_name: "gpt-5", + description: "Broad world knowledge with strong general reasoning.", + default_reasoning_effort: ReasoningEffort::Medium, + supported_reasoning_efforts: &[ + ReasoningEffortPreset { + effort: ReasoningEffort::Minimal, + description: "Fastest responses with little reasoning", + }, + ReasoningEffortPreset { + effort: ReasoningEffort::Low, + description: "Balances speed with some reasoning; useful for straightforward queries and short explanations", + }, + ReasoningEffortPreset { + effort: ReasoningEffort::Medium, + description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks", + }, + ReasoningEffortPreset { + effort: ReasoningEffort::High, + description: "Maximizes reasoning depth for complex or ambiguous problems", + }, + ], + is_default: false, }, ]; -pub fn builtin_model_presets(_auth_mode: Option) -> Vec { - PRESETS.to_vec() +pub fn builtin_model_presets(auth_mode: Option) -> Vec { + let allow_codex_mini = matches!(auth_mode, Some(AuthMode::ChatGPT)); + PRESETS + .iter() + .filter(|preset| allow_codex_mini || preset.id != "gpt-5-codex-mini") + .copied() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_one_default_model_is_configured() { + let default_models = PRESETS.iter().filter(|preset| preset.is_default).count(); + assert!(default_models == 1); + } } diff --git a/codex-rs/config.md b/codex-rs/config.md index af51df4197..5de2a2d9c4 100644 --- a/codex-rs/config.md +++ b/codex-rs/config.md @@ -3,4 +3,4 @@ This file has moved. Please see the latest configuration documentation here: - Full config docs: [docs/config.md](../docs/config.md) -- MCP servers section: [docs/config.md#mcp_servers](../docs/config.md#mcp_servers) \ No newline at end of file +- MCP servers section: [docs/config.md#connecting-to-mcp-servers](../docs/config.md#connecting-to-mcp-servers) diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 4259e64fc4..17a7a16609 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -21,32 +21,45 @@ bytes = { workspace = true } chrono = { workspace = true, features = ["serde"] } codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } +codex-async-utils = { workspace = true } codex-file-search = { workspace = true } -codex-mcp-client = { workspace = true } +codex-git = { workspace = true } +codex-keyring-store = { workspace = true } codex-otel = { workspace = true, features = ["otel"] } codex-protocol = { workspace = true } codex-rmcp-client = { workspace = true } +codex-utils-pty = { workspace = true } +codex-utils-readiness = { workspace = true } codex-utils-string = { workspace = true } +codex-utils-tokenizer = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } env-flags = { workspace = true } eventsource-stream = { workspace = true } futures = { workspace = true } +http = { workspace = true } indexmap = { workspace = true } +keyring = { workspace = true, features = [ + "apple-native", + "crypto-rust", + "linux-native-async-persistent", + "windows-native", +] } libc = { workspace = true } mcp-types = { workspace = true } os_info = { workspace = true } -portable-pty = { workspace = true } rand = { workspace = true } regex-lite = { workspace = true } reqwest = { workspace = true, features = ["json", "stream"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha1 = { workspace = true } +sha2 = { workspace = true } shlex = { workspace = true } similar = { workspace = true } strum_macros = { workspace = true } tempfile = { workspace = true } +test-log = { workspace = true } thiserror = { workspace = true } time = { workspace = true, features = [ "formatting", @@ -67,9 +80,10 @@ toml_edit = { workspace = true } tracing = { workspace = true, features = ["log"] } tree-sitter = { workspace = true } tree-sitter-bash = { workspace = true } -uuid = { workspace = true, features = ["serde", "v4"] } +uuid = { workspace = true, features = ["serde", "v4", "v5"] } which = { workspace = true } wildmatch = { workspace = true } +codex_windows_sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" } [target.'cfg(target_os = "linux")'.dependencies] @@ -92,6 +106,7 @@ assert_cmd = { workspace = true } assert_matches = { workspace = true } core_test_support = { workspace = true } escargot = { workspace = true } +image = { workspace = true, features = ["jpeg", "png"] } maplit = { workspace = true } predicates = { workspace = true } pretty_assertions = { workspace = true } diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index 8260fc1e79..5d4911b022 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -4,7 +4,7 @@ This crate implements the business logic for Codex. It is designed to be used by ## Dependencies -Note that `codex-core` makes some assumptions about certain helper utilities being available in the environment. Currently, this +Note that `codex-core` makes some assumptions about certain helper utilities being available in the environment. Currently, this support matrix is: ### macOS diff --git a/codex-rs/core/gpt_5_codex_prompt.md b/codex-rs/core/gpt_5_codex_prompt.md index 3abec0c831..e3cbfa0f25 100644 --- a/codex-rs/core/gpt_5_codex_prompt.md +++ b/codex-rs/core/gpt_5_codex_prompt.md @@ -16,6 +16,7 @@ You are Codex, based on GPT-5. You are running as a coding agent in the Codex CL * If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes. * If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them. * If the changes are in unrelated files, just ignore them and don't revert them. +- Do not amend a commit unless explicitly requested to do so. - While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed. - **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user. diff --git a/codex-rs/core/review_prompt.md b/codex-rs/core/review_prompt.md index 01d93598a7..040f06ba94 100644 --- a/codex-rs/core/review_prompt.md +++ b/codex-rs/core/review_prompt.md @@ -82,6 +82,6 @@ OUTPUT FORMAT: * **Do not** wrap the JSON in markdown fences or extra prose. * The code_location field is required and must include absolute_file_path and line_range. -*Line ranges must be as short as possible for interpreting the issue (avoid ranges over 5–10 lines; pick the most suitable subrange). +* Line ranges must be as short as possible for interpreting the issue (avoid ranges over 5–10 lines; pick the most suitable subrange). * The code_location should overlap with the diff. -* Do not generate a PR fix. \ No newline at end of file +* Do not generate a PR fix. diff --git a/codex-rs/core/src/apply_patch.rs b/codex-rs/core/src/apply_patch.rs index 5b6728ad0f..dffe94be61 100644 --- a/codex-rs/core/src/apply_patch.rs +++ b/codex-rs/core/src/apply_patch.rs @@ -36,7 +36,6 @@ pub(crate) struct ApplyPatchExec { pub(crate) async fn apply_patch( sess: &Session, turn_context: &TurnContext, - sub_id: &str, call_id: &str, action: ApplyPatchAction, ) -> InternalApplyPatchInvocation { @@ -62,7 +61,13 @@ pub(crate) async fn apply_patch( // that similar patches can be auto-approved in the future during // this session. let rx_approve = sess - .request_patch_approval(sub_id.to_owned(), call_id.to_owned(), &action, None, None) + .request_patch_approval( + turn_context, + call_id.to_owned(), + convert_apply_patch_to_protocol(&action), + None, + None, + ) .await; match rx_approve.await.unwrap_or_default() { ReviewDecision::Approved | ReviewDecision::ApprovedForSession => { diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 21cb4bf6d6..a9d6406001 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -1,14 +1,14 @@ -use chrono::DateTime; +mod storage; + use chrono::Utc; +use reqwest::StatusCode; use serde::Deserialize; use serde::Serialize; +#[cfg(test)] +use serial_test::serial; use std::env; -use std::fs::File; -use std::fs::OpenOptions; -use std::io::Read; -use std::io::Write; -#[cfg(unix)] -use std::os::unix::fs::OpenOptionsExt; +use std::fmt::Debug; +use std::io::ErrorKind; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; @@ -16,10 +16,24 @@ use std::sync::Mutex; use std::time::Duration; use codex_app_server_protocol::AuthMode; +use codex_protocol::config_types::ForcedLoginMethod; -use crate::token_data::PlanType; +pub use crate::auth::storage::AuthCredentialsStoreMode; +pub use crate::auth::storage::AuthDotJson; +use crate::auth::storage::AuthStorageBackend; +use crate::auth::storage::create_auth_storage; +use crate::config::Config; +use crate::default_client::CodexHttpClient; +use crate::error::RefreshTokenFailedError; +use crate::error::RefreshTokenFailedReason; +use crate::token_data::KnownPlan as InternalKnownPlan; +use crate::token_data::PlanType as InternalPlanType; use crate::token_data::TokenData; use crate::token_data::parse_id_token; +use crate::util::try_parse_error_message; +use codex_protocol::account::PlanType as AccountPlanType; +use serde_json::Value; +use thiserror::Error; #[derive(Debug, Clone)] pub struct CodexAuth { @@ -27,8 +41,8 @@ pub struct CodexAuth { pub(crate) api_key: Option, pub(crate) auth_dot_json: Arc>>, - pub(crate) auth_file: PathBuf, - pub(crate) client: reqwest::Client, + storage: Arc, + pub(crate) client: CodexHttpClient, } impl PartialEq for CodexAuth { @@ -37,24 +51,66 @@ impl PartialEq for CodexAuth { } } +// TODO(pakrym): use token exp field to check for expiration instead +const TOKEN_REFRESH_INTERVAL: i64 = 8; + +const REFRESH_TOKEN_EXPIRED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token has expired. Please log out and sign in again."; +const REFRESH_TOKEN_REUSED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again."; +const REFRESH_TOKEN_INVALIDATED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token was revoked. Please log out and sign in again."; +const REFRESH_TOKEN_UNKNOWN_MESSAGE: &str = + "Your access token could not be refreshed. Please log out and sign in again."; +const REFRESH_TOKEN_URL: &str = "https://auth.openai.com/oauth/token"; +pub const REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR: &str = "CODEX_REFRESH_TOKEN_URL_OVERRIDE"; + +#[derive(Debug, Error)] +pub enum RefreshTokenError { + #[error("{0}")] + Permanent(#[from] RefreshTokenFailedError), + #[error(transparent)] + Transient(#[from] std::io::Error), +} + +impl RefreshTokenError { + pub fn failed_reason(&self) -> Option { + match self { + Self::Permanent(error) => Some(error.reason), + Self::Transient(_) => None, + } + } + + fn other_with_message(message: impl Into) -> Self { + Self::Transient(std::io::Error::other(message.into())) + } +} + +impl From for std::io::Error { + fn from(err: RefreshTokenError) -> Self { + match err { + RefreshTokenError::Permanent(failed) => std::io::Error::other(failed), + RefreshTokenError::Transient(inner) => inner, + } + } +} + impl CodexAuth { - pub async fn refresh_token(&self) -> Result { - let token_data = self - .get_current_token_data() - .ok_or(std::io::Error::other("Token data is not available."))?; + pub async fn refresh_token(&self) -> Result { + tracing::info!("Refreshing token"); + + let token_data = self.get_current_token_data().ok_or_else(|| { + RefreshTokenError::Transient(std::io::Error::other("Token data is not available.")) + })?; let token = token_data.refresh_token; - let refresh_response = try_refresh_token(token, &self.client) - .await - .map_err(std::io::Error::other)?; + let refresh_response = try_refresh_token(token, &self.client).await?; let updated = update_tokens( - &self.auth_file, + &self.storage, refresh_response.id_token, refresh_response.access_token, refresh_response.refresh_token, ) - .await?; + .await + .map_err(RefreshTokenError::from)?; if let Ok(mut auth_lock) = self.auth_dot_json.lock() { *auth_lock = Some(updated.clone()); @@ -63,7 +119,7 @@ impl CodexAuth { let access = match updated.tokens { Some(t) => t.access_token, None => { - return Err(std::io::Error::other( + return Err(RefreshTokenError::other_with_message( "Token data is not available after refresh.", )); } @@ -71,9 +127,12 @@ impl CodexAuth { Ok(access) } - /// Loads the available auth information from the auth.json. - pub fn from_codex_home(codex_home: &Path) -> std::io::Result> { - load_auth(codex_home, false) + /// Loads the available auth information from auth storage. + pub fn from_auth_storage( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, + ) -> std::io::Result> { + load_auth(codex_home, false, auth_credentials_store_mode) } pub async fn get_token_data(&self) -> Result { @@ -84,19 +143,25 @@ impl CodexAuth { last_refresh: Some(last_refresh), .. }) => { - if last_refresh < Utc::now() - chrono::Duration::days(28) { - let refresh_response = tokio::time::timeout( + if last_refresh < Utc::now() - chrono::Duration::days(TOKEN_REFRESH_INTERVAL) { + let refresh_result = tokio::time::timeout( Duration::from_secs(60), try_refresh_token(tokens.refresh_token.clone(), &self.client), ) - .await - .map_err(|_| { - std::io::Error::other("timed out while refreshing OpenAI API key") - })? - .map_err(std::io::Error::other)?; + .await; + let refresh_response = match refresh_result { + Ok(Ok(response)) => response, + Ok(Err(err)) => return Err(err.into()), + Err(_) => { + return Err(std::io::Error::new( + ErrorKind::TimedOut, + "timed out while refreshing OpenAI API key", + )); + } + }; let updated_auth_dot_json = update_tokens( - &self.auth_file, + &self.storage, refresh_response.id_token, refresh_response.access_token, refresh_response.refresh_token, @@ -139,7 +204,34 @@ impl CodexAuth { self.get_current_token_data().and_then(|t| t.id_token.email) } - pub(crate) fn get_plan_type(&self) -> Option { + /// Account-facing plan classification derived from the current token. + /// Returns a high-level `AccountPlanType` (e.g., Free/Plus/Pro/Team/…) + /// mapped from the ID token's internal plan value. Prefer this when you + /// need to make UI or product decisions based on the user's subscription. + pub fn account_plan_type(&self) -> Option { + let map_known = |kp: &InternalKnownPlan| match kp { + InternalKnownPlan::Free => AccountPlanType::Free, + InternalKnownPlan::Plus => AccountPlanType::Plus, + InternalKnownPlan::Pro => AccountPlanType::Pro, + InternalKnownPlan::Team => AccountPlanType::Team, + InternalKnownPlan::Business => AccountPlanType::Business, + InternalKnownPlan::Enterprise => AccountPlanType::Enterprise, + InternalKnownPlan::Edu => AccountPlanType::Edu, + }; + + self.get_current_token_data() + .and_then(|t| t.id_token.chatgpt_plan_type) + .map(|pt| match pt { + InternalPlanType::Known(k) => map_known(&k), + InternalPlanType::Unknown(_) => AccountPlanType::Unknown, + }) + } + + /// Raw internal plan value from the ID token. + /// Exposes the underlying `token_data::PlanType` without mapping it to the + /// public `AccountPlanType`. Use this when downstream code needs to inspect + /// internal/unknown plan strings exactly as issued in the token. + pub(crate) fn get_plan_type(&self) -> Option { self.get_current_token_data() .and_then(|t| t.id_token.chatgpt_plan_type) } @@ -170,17 +262,17 @@ impl CodexAuth { Self { api_key: None, mode: AuthMode::ChatGPT, - auth_file: PathBuf::new(), + storage: create_auth_storage(PathBuf::new(), AuthCredentialsStoreMode::File), auth_dot_json, client: crate::default_client::create_client(), } } - fn from_api_key_with_client(api_key: &str, client: reqwest::Client) -> Self { + fn from_api_key_with_client(api_key: &str, client: CodexHttpClient) -> Self { Self { api_key: Some(api_key.to_owned()), mode: AuthMode::ApiKey, - auth_file: PathBuf::new(), + storage: create_auth_storage(PathBuf::new(), AuthCredentialsStoreMode::File), auth_dot_json: Arc::new(Mutex::new(None)), client, } @@ -208,34 +300,140 @@ pub fn read_codex_api_key_from_env() -> Option { .filter(|value| !value.is_empty()) } -pub fn get_auth_file(codex_home: &Path) -> PathBuf { - codex_home.join("auth.json") -} - /// Delete the auth.json file inside `codex_home` if it exists. Returns `Ok(true)` /// if a file was removed, `Ok(false)` if no auth file was present. -pub fn logout(codex_home: &Path) -> std::io::Result { - let auth_file = get_auth_file(codex_home); - match std::fs::remove_file(&auth_file) { - Ok(_) => Ok(true), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(err) => Err(err), - } +pub fn logout( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result { + let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); + storage.delete() } /// Writes an `auth.json` that contains only the API key. -pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<()> { +pub fn login_with_api_key( + codex_home: &Path, + api_key: &str, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result<()> { let auth_dot_json = AuthDotJson { openai_api_key: Some(api_key.to_string()), tokens: None, last_refresh: None, }; - write_auth_json(&get_auth_file(codex_home), &auth_dot_json) + save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) +} + +/// Persist the provided auth payload using the specified backend. +pub fn save_auth( + codex_home: &Path, + auth: &AuthDotJson, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result<()> { + let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); + storage.save(auth) +} + +/// Load CLI auth data using the configured credential store backend. +/// Returns `None` when no credentials are stored. +pub fn load_auth_dot_json( + codex_home: &Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result> { + let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); + storage.load() +} + +pub async fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> { + let Some(auth) = load_auth( + &config.codex_home, + true, + config.cli_auth_credentials_store_mode, + )? + else { + return Ok(()); + }; + + if let Some(required_method) = config.forced_login_method { + let method_violation = match (required_method, auth.mode) { + (ForcedLoginMethod::Api, AuthMode::ApiKey) => None, + (ForcedLoginMethod::Chatgpt, AuthMode::ChatGPT) => None, + (ForcedLoginMethod::Api, AuthMode::ChatGPT) => Some( + "API key login is required, but ChatGPT is currently being used. Logging out." + .to_string(), + ), + (ForcedLoginMethod::Chatgpt, AuthMode::ApiKey) => Some( + "ChatGPT login is required, but an API key is currently being used. Logging out." + .to_string(), + ), + }; + + if let Some(message) = method_violation { + return logout_with_message( + &config.codex_home, + message, + config.cli_auth_credentials_store_mode, + ); + } + } + + if let Some(expected_account_id) = config.forced_chatgpt_workspace_id.as_deref() { + if auth.mode != AuthMode::ChatGPT { + return Ok(()); + } + + let token_data = match auth.get_token_data().await { + Ok(data) => data, + Err(err) => { + return logout_with_message( + &config.codex_home, + format!( + "Failed to load ChatGPT credentials while enforcing workspace restrictions: {err}. Logging out." + ), + config.cli_auth_credentials_store_mode, + ); + } + }; + + // workspace is the external identifier for account id. + let chatgpt_account_id = token_data.id_token.chatgpt_account_id.as_deref(); + if chatgpt_account_id != Some(expected_account_id) { + let message = match chatgpt_account_id { + Some(actual) => format!( + "Login is restricted to workspace {expected_account_id}, but current credentials belong to {actual}. Logging out." + ), + None => format!( + "Login is restricted to workspace {expected_account_id}, but current credentials lack a workspace identifier. Logging out." + ), + }; + return logout_with_message( + &config.codex_home, + message, + config.cli_auth_credentials_store_mode, + ); + } + } + + Ok(()) +} + +fn logout_with_message( + codex_home: &Path, + message: String, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> std::io::Result<()> { + match logout(codex_home, auth_credentials_store_mode) { + Ok(_) => Err(std::io::Error::other(message)), + Err(err) => Err(std::io::Error::other(format!( + "{message}. Failed to remove auth.json: {err}" + ))), + } } fn load_auth( codex_home: &Path, enable_codex_api_key_env: bool, + auth_credentials_store_mode: AuthCredentialsStoreMode, ) -> std::io::Result> { if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() { let client = crate::default_client::create_client(); @@ -245,13 +443,12 @@ fn load_auth( ))); } - let auth_file = get_auth_file(codex_home); + let storage = create_auth_storage(codex_home.to_path_buf(), auth_credentials_store_mode); + let client = crate::default_client::create_client(); - let auth_dot_json = match try_read_auth_json(&auth_file) { - Ok(auth) => auth, - Err(e) => { - return Err(e); - } + let auth_dot_json = match storage.load()? { + Some(auth) => auth, + None => return Ok(None), }; let AuthDotJson { @@ -268,7 +465,7 @@ fn load_auth( Ok(Some(CodexAuth { api_key: None, mode: AuthMode::ChatGPT, - auth_file, + storage: storage.clone(), auth_dot_json: Arc::new(Mutex::new(Some(AuthDotJson { openai_api_key: None, tokens, @@ -278,44 +475,20 @@ fn load_auth( })) } -/// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory. -/// Returns the full AuthDotJson structure after refreshing if necessary. -pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result { - let mut file = File::open(auth_file)?; - let mut contents = String::new(); - file.read_to_string(&mut contents)?; - let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?; - - Ok(auth_dot_json) -} - -pub fn write_auth_json(auth_file: &Path, auth_dot_json: &AuthDotJson) -> std::io::Result<()> { - if let Some(parent) = auth_file.parent() { - std::fs::create_dir_all(parent)?; - } - let json_data = serde_json::to_string_pretty(auth_dot_json)?; - let mut options = OpenOptions::new(); - options.truncate(true).write(true).create(true); - #[cfg(unix)] - { - options.mode(0o600); - } - let mut file = options.open(auth_file)?; - file.write_all(json_data.as_bytes())?; - file.flush()?; - Ok(()) -} - async fn update_tokens( - auth_file: &Path, - id_token: String, + storage: &Arc, + id_token: Option, access_token: Option, refresh_token: Option, ) -> std::io::Result { - let mut auth_dot_json = try_read_auth_json(auth_file)?; + let mut auth_dot_json = storage + .load()? + .ok_or(std::io::Error::other("Token data is not available."))?; let tokens = auth_dot_json.tokens.get_or_insert_with(TokenData::default); - tokens.id_token = parse_id_token(&id_token).map_err(std::io::Error::other)?; + if let Some(id_token) = id_token { + tokens.id_token = parse_id_token(&id_token).map_err(std::io::Error::other)?; + } if let Some(access_token) = access_token { tokens.access_token = access_token; } @@ -323,14 +496,14 @@ async fn update_tokens( tokens.refresh_token = refresh_token; } auth_dot_json.last_refresh = Some(Utc::now()); - write_auth_json(auth_file, &auth_dot_json)?; + storage.save(&auth_dot_json)?; Ok(auth_dot_json) } async fn try_refresh_token( refresh_token: String, - client: &reqwest::Client, -) -> std::io::Result { + client: &CodexHttpClient, +) -> Result { let refresh_request = RefreshRequest { client_id: CLIENT_ID, grant_type: "refresh_token", @@ -338,29 +511,93 @@ async fn try_refresh_token( scope: "openid profile email", }; + let endpoint = refresh_token_endpoint(); + // Use shared client factory to include standard headers let response = client - .post("https://auth.openai.com/oauth/token") + .post(endpoint.as_str()) .header("Content-Type", "application/json") .json(&refresh_request) .send() .await - .map_err(std::io::Error::other)?; + .map_err(|err| RefreshTokenError::Transient(std::io::Error::other(err)))?; - if response.status().is_success() { + let status = response.status(); + if status.is_success() { let refresh_response = response .json::() .await - .map_err(std::io::Error::other)?; + .map_err(|err| RefreshTokenError::Transient(std::io::Error::other(err)))?; Ok(refresh_response) } else { - Err(std::io::Error::other(format!( - "Failed to refresh token: {}", - response.status() - ))) + let body = response.text().await.unwrap_or_default(); + if status == StatusCode::UNAUTHORIZED { + let failed = classify_refresh_token_failure(&body); + Err(RefreshTokenError::Permanent(failed)) + } else { + let message = try_parse_error_message(&body); + Err(RefreshTokenError::Transient(std::io::Error::other( + format!("Failed to refresh token: {status}: {message}"), + ))) + } } } +fn classify_refresh_token_failure(body: &str) -> RefreshTokenFailedError { + let code = extract_refresh_token_error_code(body); + + let normalized_code = code.as_deref().map(str::to_ascii_lowercase); + let reason = match normalized_code.as_deref() { + Some("refresh_token_expired") => RefreshTokenFailedReason::Expired, + Some("refresh_token_reused") => RefreshTokenFailedReason::Exhausted, + Some("refresh_token_invalidated") => RefreshTokenFailedReason::Revoked, + _ => RefreshTokenFailedReason::Other, + }; + + if reason == RefreshTokenFailedReason::Other { + tracing::warn!( + backend_code = normalized_code.as_deref(), + backend_body = body, + "Encountered unknown 401 response while refreshing token" + ); + } + + let message = match reason { + RefreshTokenFailedReason::Expired => REFRESH_TOKEN_EXPIRED_MESSAGE.to_string(), + RefreshTokenFailedReason::Exhausted => REFRESH_TOKEN_REUSED_MESSAGE.to_string(), + RefreshTokenFailedReason::Revoked => REFRESH_TOKEN_INVALIDATED_MESSAGE.to_string(), + RefreshTokenFailedReason::Other => REFRESH_TOKEN_UNKNOWN_MESSAGE.to_string(), + }; + + RefreshTokenFailedError::new(reason, message) +} + +fn extract_refresh_token_error_code(body: &str) -> Option { + if body.trim().is_empty() { + return None; + } + + let Value::Object(map) = serde_json::from_str::(body).ok()? else { + return None; + }; + + if let Some(error_value) = map.get("error") { + match error_value { + Value::Object(obj) => { + if let Some(code) = obj.get("code").and_then(Value::as_str) { + return Some(code.to_string()); + } + } + Value::String(code) => { + return Some(code.to_string()); + } + _ => {} + } + } + + map.get("code").and_then(Value::as_str).map(str::to_string) +} + #[derive(Serialize)] struct RefreshRequest { client_id: &'static str, @@ -371,27 +608,19 @@ struct RefreshRequest { #[derive(Deserialize, Clone)] struct RefreshResponse { - id_token: String, + id_token: Option, access_token: Option, refresh_token: Option, } -/// Expected structure for $CODEX_HOME/auth.json. -#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] -pub struct AuthDotJson { - #[serde(rename = "OPENAI_API_KEY")] - pub openai_api_key: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub tokens: Option, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub last_refresh: Option>, -} - // Shared constant for token refresh (client id used for oauth token refresh flow) pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; +fn refresh_token_endpoint() -> String { + std::env::var(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR) + .unwrap_or_else(|_| REFRESH_TOKEN_URL.to_string()) +} + use std::sync::RwLock; /// Internal cached auth state. @@ -403,35 +632,53 @@ struct CachedAuth { #[cfg(test)] mod tests { use super::*; + use crate::auth::storage::FileAuthStorage; + use crate::auth::storage::get_auth_file; + use crate::config::Config; + use crate::config::ConfigOverrides; + use crate::config::ConfigToml; use crate::token_data::IdTokenInfo; - use crate::token_data::KnownPlan; - use crate::token_data::PlanType; + use crate::token_data::KnownPlan as InternalKnownPlan; + use crate::token_data::PlanType as InternalPlanType; + use codex_protocol::account::PlanType as AccountPlanType; + use base64::Engine; + use codex_protocol::config_types::ForcedLoginMethod; use pretty_assertions::assert_eq; use serde::Serialize; use serde_json::json; use tempfile::tempdir; - const LAST_REFRESH: &str = "2025-08-06T20:41:36.232376Z"; - #[tokio::test] - async fn roundtrip_auth_dot_json() { + async fn refresh_without_id_token() { let codex_home = tempdir().unwrap(); - let _ = write_auth_file( + let fake_jwt = write_auth_file( AuthFileParams { openai_api_key: None, chatgpt_plan_type: "pro".to_string(), + chatgpt_account_id: None, }, codex_home.path(), ) .expect("failed to write auth file"); - let file = get_auth_file(codex_home.path()); - let auth_dot_json = try_read_auth_json(&file).unwrap(); - write_auth_json(&file, &auth_dot_json).unwrap(); + let storage = create_auth_storage( + codex_home.path().to_path_buf(), + AuthCredentialsStoreMode::File, + ); + let updated = super::update_tokens( + &storage, + None, + Some("new-access-token".to_string()), + Some("new-refresh-token".to_string()), + ) + .await + .expect("update_tokens should succeed"); - let same_auth_dot_json = try_read_auth_json(&file).unwrap(); - assert_eq!(auth_dot_json, same_auth_dot_json); + let tokens = updated.tokens.expect("tokens should exist"); + assert_eq!(tokens.id_token.raw_jwt, fake_jwt); + assert_eq!(tokens.access_token, "new-access-token"); + assert_eq!(tokens.refresh_token, "new-refresh-token"); } #[test] @@ -453,20 +700,34 @@ mod tests { ) .unwrap(); - super::login_with_api_key(dir.path(), "sk-new").expect("login_with_api_key should succeed"); + super::login_with_api_key(dir.path(), "sk-new", AuthCredentialsStoreMode::File) + .expect("login_with_api_key should succeed"); - let auth = super::try_read_auth_json(&auth_path).expect("auth.json should parse"); + let storage = FileAuthStorage::new(dir.path().to_path_buf()); + let auth = storage + .try_read_auth_json(&auth_path) + .expect("auth.json should parse"); assert_eq!(auth.openai_api_key.as_deref(), Some("sk-new")); assert!(auth.tokens.is_none(), "tokens should be cleared"); } + #[test] + fn missing_auth_json_returns_none() { + let dir = tempdir().unwrap(); + let auth = CodexAuth::from_auth_storage(dir.path(), AuthCredentialsStoreMode::File) + .expect("call should succeed"); + assert_eq!(auth, None); + } + #[tokio::test] + #[serial(codex_api_key)] async fn pro_account_with_no_api_key_uses_chatgpt_auth() { let codex_home = tempdir().unwrap(); let fake_jwt = write_auth_file( AuthFileParams { openai_api_key: None, chatgpt_plan_type: "pro".to_string(), + chatgpt_account_id: None, }, codex_home.path(), ) @@ -476,38 +737,42 @@ mod tests { api_key, mode, auth_dot_json, - auth_file: _, + storage: _, .. - } = super::load_auth(codex_home.path(), false).unwrap().unwrap(); + } = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File) + .unwrap() + .unwrap(); assert_eq!(None, api_key); assert_eq!(AuthMode::ChatGPT, mode); let guard = auth_dot_json.lock().unwrap(); let auth_dot_json = guard.as_ref().expect("AuthDotJson should exist"); + let last_refresh = auth_dot_json + .last_refresh + .expect("last_refresh should be recorded"); + assert_eq!( &AuthDotJson { openai_api_key: None, tokens: Some(TokenData { id_token: IdTokenInfo { email: Some("user@example.com".to_string()), - chatgpt_plan_type: Some(PlanType::Known(KnownPlan::Pro)), + chatgpt_plan_type: Some(InternalPlanType::Known(InternalKnownPlan::Pro)), + chatgpt_account_id: None, raw_jwt: fake_jwt, }, access_token: "test-access-token".to_string(), refresh_token: "test-refresh-token".to_string(), account_id: None, }), - last_refresh: Some( - DateTime::parse_from_rfc3339(LAST_REFRESH) - .unwrap() - .with_timezone(&Utc) - ), + last_refresh: Some(last_refresh), }, auth_dot_json - ) + ); } #[tokio::test] + #[serial(codex_api_key)] async fn loads_api_key_from_auth_json() { let dir = tempdir().unwrap(); let auth_file = dir.path().join("auth.json"); @@ -517,7 +782,9 @@ mod tests { ) .unwrap(); - let auth = super::load_auth(dir.path(), false).unwrap().unwrap(); + let auth = super::load_auth(dir.path(), false, AuthCredentialsStoreMode::File) + .unwrap() + .unwrap(); assert_eq!(auth.mode, AuthMode::ApiKey); assert_eq!(auth.api_key, Some("sk-test-key".to_string())); @@ -532,17 +799,18 @@ mod tests { tokens: None, last_refresh: None, }; - write_auth_json(&get_auth_file(dir.path()), &auth_dot_json)?; - assert!(dir.path().join("auth.json").exists()); - let removed = logout(dir.path())?; - assert!(removed); - assert!(!dir.path().join("auth.json").exists()); + super::save_auth(dir.path(), &auth_dot_json, AuthCredentialsStoreMode::File)?; + let auth_file = get_auth_file(dir.path()); + assert!(auth_file.exists()); + assert!(logout(dir.path(), AuthCredentialsStoreMode::File)?); + assert!(!auth_file.exists()); Ok(()) } struct AuthFileParams { openai_api_key: Option, chatgpt_plan_type: String, + chatgpt_account_id: Option, } fn write_auth_file(params: AuthFileParams, codex_home: &Path) -> std::io::Result { @@ -557,15 +825,21 @@ mod tests { alg: "none", typ: "JWT", }; + let mut auth_payload = serde_json::json!({ + "chatgpt_plan_type": params.chatgpt_plan_type, + "chatgpt_user_id": "user-12345", + "user_id": "user-12345", + }); + + if let Some(chatgpt_account_id) = params.chatgpt_account_id { + let org_value = serde_json::Value::String(chatgpt_account_id); + auth_payload["chatgpt_account_id"] = org_value; + } + let payload = serde_json::json!({ "email": "user@example.com", "email_verified": true, - "https://api.openai.com/auth": { - "chatgpt_account_id": "bc3618e3-489d-4d49-9362-1561dc53ba53", - "chatgpt_plan_type": params.chatgpt_plan_type, - "chatgpt_user_id": "user-12345", - "user_id": "user-12345", - } + "https://api.openai.com/auth": auth_payload, }); let b64 = |b: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b); let header_b64 = b64(&serde_json::to_vec(&header)?); @@ -580,12 +854,211 @@ mod tests { "access_token": "test-access-token", "refresh_token": "test-refresh-token" }, - "last_refresh": LAST_REFRESH, + "last_refresh": Utc::now(), }); let auth_json = serde_json::to_string_pretty(&auth_json_data)?; std::fs::write(auth_file, auth_json)?; Ok(fake_jwt) } + + fn build_config( + codex_home: &Path, + forced_login_method: Option, + forced_chatgpt_workspace_id: Option, + ) -> Config { + let mut config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + codex_home.to_path_buf(), + ) + .expect("config should load"); + config.forced_login_method = forced_login_method; + config.forced_chatgpt_workspace_id = forced_chatgpt_workspace_id; + config + } + + /// Use sparingly. + /// TODO (gpeal): replace this with an injectable env var provider. + #[cfg(test)] + struct EnvVarGuard { + key: &'static str, + original: Option, + } + + #[cfg(test)] + impl EnvVarGuard { + fn set(key: &'static str, value: &str) -> Self { + let original = env::var_os(key); + unsafe { + env::set_var(key, value); + } + Self { key, original } + } + } + + #[cfg(test)] + impl Drop for EnvVarGuard { + fn drop(&mut self) { + unsafe { + match &self.original { + Some(value) => env::set_var(self.key, value), + None => env::remove_var(self.key), + } + } + } + } + + #[tokio::test] + async fn enforce_login_restrictions_logs_out_for_method_mismatch() { + let codex_home = tempdir().unwrap(); + login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File) + .expect("seed api key"); + + let config = build_config(codex_home.path(), Some(ForcedLoginMethod::Chatgpt), None); + + let err = super::enforce_login_restrictions(&config) + .await + .expect_err("expected method mismatch to error"); + assert!(err.to_string().contains("ChatGPT login is required")); + assert!( + !codex_home.path().join("auth.json").exists(), + "auth.json should be removed on mismatch" + ); + } + + #[tokio::test] + #[serial(codex_api_key)] + async fn enforce_login_restrictions_logs_out_for_workspace_mismatch() { + let codex_home = tempdir().unwrap(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: "pro".to_string(), + chatgpt_account_id: Some("org_another_org".to_string()), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let config = build_config(codex_home.path(), None, Some("org_mine".to_string())); + + let err = super::enforce_login_restrictions(&config) + .await + .expect_err("expected workspace mismatch to error"); + assert!(err.to_string().contains("workspace org_mine")); + assert!( + !codex_home.path().join("auth.json").exists(), + "auth.json should be removed on mismatch" + ); + } + + #[tokio::test] + #[serial(codex_api_key)] + async fn enforce_login_restrictions_allows_matching_workspace() { + let codex_home = tempdir().unwrap(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: "pro".to_string(), + chatgpt_account_id: Some("org_mine".to_string()), + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let config = build_config(codex_home.path(), None, Some("org_mine".to_string())); + + super::enforce_login_restrictions(&config) + .await + .expect("matching workspace should succeed"); + assert!( + codex_home.path().join("auth.json").exists(), + "auth.json should remain when restrictions pass" + ); + } + + #[tokio::test] + async fn enforce_login_restrictions_allows_api_key_if_login_method_not_set_but_forced_chatgpt_workspace_id_is_set() + { + let codex_home = tempdir().unwrap(); + login_with_api_key(codex_home.path(), "sk-test", AuthCredentialsStoreMode::File) + .expect("seed api key"); + + let config = build_config(codex_home.path(), None, Some("org_mine".to_string())); + + super::enforce_login_restrictions(&config) + .await + .expect("matching workspace should succeed"); + assert!( + codex_home.path().join("auth.json").exists(), + "auth.json should remain when restrictions pass" + ); + } + + #[tokio::test] + #[serial(codex_api_key)] + async fn enforce_login_restrictions_blocks_env_api_key_when_chatgpt_required() { + let _guard = EnvVarGuard::set(CODEX_API_KEY_ENV_VAR, "sk-env"); + let codex_home = tempdir().unwrap(); + + let config = build_config(codex_home.path(), Some(ForcedLoginMethod::Chatgpt), None); + + let err = super::enforce_login_restrictions(&config) + .await + .expect_err("environment API key should not satisfy forced ChatGPT login"); + assert!( + err.to_string() + .contains("ChatGPT login is required, but an API key is currently being used.") + ); + } + + #[test] + fn plan_type_maps_known_plan() { + let codex_home = tempdir().unwrap(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: "pro".to_string(), + chatgpt_account_id: None, + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File) + .expect("load auth") + .expect("auth available"); + + pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Pro)); + pretty_assertions::assert_eq!( + auth.get_plan_type(), + Some(InternalPlanType::Known(InternalKnownPlan::Pro)) + ); + } + + #[test] + fn plan_type_maps_unknown_to_unknown() { + let codex_home = tempdir().unwrap(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: "mystery-tier".to_string(), + chatgpt_account_id: None, + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File) + .expect("load auth") + .expect("auth available"); + + pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Unknown)); + pretty_assertions::assert_eq!( + auth.get_plan_type(), + Some(InternalPlanType::Unknown("mystery-tier".to_string())) + ); + } } /// Central manager providing a single source of truth for auth.json derived @@ -601,6 +1074,7 @@ pub struct AuthManager { codex_home: PathBuf, inner: RwLock, enable_codex_api_key_env: bool, + auth_credentials_store_mode: AuthCredentialsStoreMode, } impl AuthManager { @@ -608,14 +1082,23 @@ impl AuthManager { /// preferred auth method. Errors loading auth are swallowed; `auth()` will /// simply return `None` in that case so callers can treat it as an /// unauthenticated state. - pub fn new(codex_home: PathBuf, enable_codex_api_key_env: bool) -> Self { - let auth = load_auth(&codex_home, enable_codex_api_key_env) - .ok() - .flatten(); + pub fn new( + codex_home: PathBuf, + enable_codex_api_key_env: bool, + auth_credentials_store_mode: AuthCredentialsStoreMode, + ) -> Self { + let auth = load_auth( + &codex_home, + enable_codex_api_key_env, + auth_credentials_store_mode, + ) + .ok() + .flatten(); Self { codex_home, inner: RwLock::new(CachedAuth { auth }), enable_codex_api_key_env, + auth_credentials_store_mode, } } @@ -626,6 +1109,7 @@ impl AuthManager { codex_home: PathBuf::new(), inner: RwLock::new(cached), enable_codex_api_key_env: false, + auth_credentials_store_mode: AuthCredentialsStoreMode::File, }) } @@ -637,9 +1121,13 @@ impl AuthManager { /// Force a reload of the auth information from auth.json. Returns /// whether the auth value changed. pub fn reload(&self) -> bool { - let new_auth = load_auth(&self.codex_home, self.enable_codex_api_key_env) - .ok() - .flatten(); + let new_auth = load_auth( + &self.codex_home, + self.enable_codex_api_key_env, + self.auth_credentials_store_mode, + ) + .ok() + .flatten(); if let Ok(mut guard) = self.inner.write() { let changed = !AuthManager::auths_equal(&guard.auth, &new_auth); guard.auth = new_auth; @@ -658,13 +1146,23 @@ impl AuthManager { } /// Convenience constructor returning an `Arc` wrapper. - pub fn shared(codex_home: PathBuf, enable_codex_api_key_env: bool) -> Arc { - Arc::new(Self::new(codex_home, enable_codex_api_key_env)) + pub fn shared( + codex_home: PathBuf, + enable_codex_api_key_env: bool, + auth_credentials_store_mode: AuthCredentialsStoreMode, + ) -> Arc { + Arc::new(Self::new( + codex_home, + enable_codex_api_key_env, + auth_credentials_store_mode, + )) } /// Attempt to refresh the current auth token (if any). On success, reload /// the auth state from disk so other components observe refreshed token. - pub async fn refresh_token(&self) -> std::io::Result> { + /// If the token refresh fails in a permanent (non‑transient) way, logs out + /// to clear invalid auth state. + pub async fn refresh_token(&self) -> Result, RefreshTokenError> { let auth = match self.auth() { Some(a) => a, None => return Ok(None), @@ -675,7 +1173,10 @@ impl AuthManager { self.reload(); Ok(Some(token)) } - Err(e) => Err(e), + Err(e) => { + tracing::error!("Failed to refresh token: {}", e); + Err(e) + } } } @@ -684,7 +1185,7 @@ impl AuthManager { /// reloads the in‑memory auth cache so callers immediately observe the /// unauthenticated state. pub fn logout(&self) -> std::io::Result { - let removed = super::auth::logout(&self.codex_home)?; + let removed = super::auth::logout(&self.codex_home, self.auth_credentials_store_mode)?; // Always reload to clear any cached auth (even if file absent). self.reload(); Ok(removed) diff --git a/codex-rs/core/src/auth/storage.rs b/codex-rs/core/src/auth/storage.rs new file mode 100644 index 0000000000..a238eb9c38 --- /dev/null +++ b/codex-rs/core/src/auth/storage.rs @@ -0,0 +1,672 @@ +use chrono::DateTime; +use chrono::Utc; +use serde::Deserialize; +use serde::Serialize; +use sha2::Digest; +use sha2::Sha256; +use std::fmt::Debug; +use std::fs::File; +use std::fs::OpenOptions; +use std::io::Read; +use std::io::Write; +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use tracing::warn; + +use crate::token_data::TokenData; +use codex_keyring_store::DefaultKeyringStore; +use codex_keyring_store::KeyringStore; + +/// Determine where Codex should store CLI auth credentials. +#[derive(Debug, Default, Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum AuthCredentialsStoreMode { + #[default] + /// Persist credentials in CODEX_HOME/auth.json. + File, + /// Persist credentials in the keyring. Fail if unavailable. + Keyring, + /// Use keyring when available; otherwise, fall back to a file in CODEX_HOME. + Auto, +} + +/// Expected structure for $CODEX_HOME/auth.json. +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq)] +pub struct AuthDotJson { + #[serde(rename = "OPENAI_API_KEY")] + pub openai_api_key: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tokens: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_refresh: Option>, +} + +pub(super) fn get_auth_file(codex_home: &Path) -> PathBuf { + codex_home.join("auth.json") +} + +pub(super) fn delete_file_if_exists(codex_home: &Path) -> std::io::Result { + let auth_file = get_auth_file(codex_home); + match std::fs::remove_file(&auth_file) { + Ok(()) => Ok(true), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(err) => Err(err), + } +} + +pub(super) trait AuthStorageBackend: Debug + Send + Sync { + fn load(&self) -> std::io::Result>; + fn save(&self, auth: &AuthDotJson) -> std::io::Result<()>; + fn delete(&self) -> std::io::Result; +} + +#[derive(Clone, Debug)] +pub(super) struct FileAuthStorage { + codex_home: PathBuf, +} + +impl FileAuthStorage { + pub(super) fn new(codex_home: PathBuf) -> Self { + Self { codex_home } + } + + /// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory. + /// Returns the full AuthDotJson structure after refreshing if necessary. + pub(super) fn try_read_auth_json(&self, auth_file: &Path) -> std::io::Result { + let mut file = File::open(auth_file)?; + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?; + + Ok(auth_dot_json) + } +} + +impl AuthStorageBackend for FileAuthStorage { + fn load(&self) -> std::io::Result> { + let auth_file = get_auth_file(&self.codex_home); + let auth_dot_json = match self.try_read_auth_json(&auth_file) { + Ok(auth) => auth, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err), + }; + Ok(Some(auth_dot_json)) + } + + fn save(&self, auth_dot_json: &AuthDotJson) -> std::io::Result<()> { + let auth_file = get_auth_file(&self.codex_home); + + if let Some(parent) = auth_file.parent() { + std::fs::create_dir_all(parent)?; + } + let json_data = serde_json::to_string_pretty(auth_dot_json)?; + let mut options = OpenOptions::new(); + options.truncate(true).write(true).create(true); + #[cfg(unix)] + { + options.mode(0o600); + } + let mut file = options.open(auth_file)?; + file.write_all(json_data.as_bytes())?; + file.flush()?; + Ok(()) + } + + fn delete(&self) -> std::io::Result { + delete_file_if_exists(&self.codex_home) + } +} + +const KEYRING_SERVICE: &str = "Codex Auth"; + +// turns codex_home path into a stable, short key string +fn compute_store_key(codex_home: &Path) -> std::io::Result { + let canonical = codex_home + .canonicalize() + .unwrap_or_else(|_| codex_home.to_path_buf()); + let path_str = canonical.to_string_lossy(); + let mut hasher = Sha256::new(); + hasher.update(path_str.as_bytes()); + let digest = hasher.finalize(); + let hex = format!("{digest:x}"); + let truncated = hex.get(..16).unwrap_or(&hex); + Ok(format!("cli|{truncated}")) +} + +#[derive(Clone, Debug)] +struct KeyringAuthStorage { + codex_home: PathBuf, + keyring_store: Arc, +} + +impl KeyringAuthStorage { + fn new(codex_home: PathBuf, keyring_store: Arc) -> Self { + Self { + codex_home, + keyring_store, + } + } + + fn load_from_keyring(&self, key: &str) -> std::io::Result> { + match self.keyring_store.load(KEYRING_SERVICE, key) { + Ok(Some(serialized)) => serde_json::from_str(&serialized).map(Some).map_err(|err| { + std::io::Error::other(format!( + "failed to deserialize CLI auth from keyring: {err}" + )) + }), + Ok(None) => Ok(None), + Err(error) => Err(std::io::Error::other(format!( + "failed to load CLI auth from keyring: {}", + error.message() + ))), + } + } + + fn save_to_keyring(&self, key: &str, value: &str) -> std::io::Result<()> { + match self.keyring_store.save(KEYRING_SERVICE, key, value) { + Ok(()) => Ok(()), + Err(error) => { + let message = format!( + "failed to write OAuth tokens to keyring: {}", + error.message() + ); + warn!("{message}"); + Err(std::io::Error::other(message)) + } + } + } +} + +impl AuthStorageBackend for KeyringAuthStorage { + fn load(&self) -> std::io::Result> { + let key = compute_store_key(&self.codex_home)?; + self.load_from_keyring(&key) + } + + fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> { + let key = compute_store_key(&self.codex_home)?; + // Simpler error mapping per style: prefer method reference over closure + let serialized = serde_json::to_string(auth).map_err(std::io::Error::other)?; + self.save_to_keyring(&key, &serialized)?; + if let Err(err) = delete_file_if_exists(&self.codex_home) { + warn!("failed to remove CLI auth fallback file: {err}"); + } + Ok(()) + } + + fn delete(&self) -> std::io::Result { + let key = compute_store_key(&self.codex_home)?; + let keyring_removed = self + .keyring_store + .delete(KEYRING_SERVICE, &key) + .map_err(|err| { + std::io::Error::other(format!("failed to delete auth from keyring: {err}")) + })?; + let file_removed = delete_file_if_exists(&self.codex_home)?; + Ok(keyring_removed || file_removed) + } +} + +#[derive(Clone, Debug)] +struct AutoAuthStorage { + keyring_storage: Arc, + file_storage: Arc, +} + +impl AutoAuthStorage { + fn new(codex_home: PathBuf, keyring_store: Arc) -> Self { + Self { + keyring_storage: Arc::new(KeyringAuthStorage::new(codex_home.clone(), keyring_store)), + file_storage: Arc::new(FileAuthStorage::new(codex_home)), + } + } +} + +impl AuthStorageBackend for AutoAuthStorage { + fn load(&self) -> std::io::Result> { + match self.keyring_storage.load() { + Ok(Some(auth)) => Ok(Some(auth)), + Ok(None) => self.file_storage.load(), + Err(err) => { + warn!("failed to load CLI auth from keyring, falling back to file storage: {err}"); + self.file_storage.load() + } + } + } + + fn save(&self, auth: &AuthDotJson) -> std::io::Result<()> { + match self.keyring_storage.save(auth) { + Ok(()) => Ok(()), + Err(err) => { + warn!("failed to save auth to keyring, falling back to file storage: {err}"); + self.file_storage.save(auth) + } + } + } + + fn delete(&self) -> std::io::Result { + // Keyring storage will delete from disk as well + self.keyring_storage.delete() + } +} + +pub(super) fn create_auth_storage( + codex_home: PathBuf, + mode: AuthCredentialsStoreMode, +) -> Arc { + let keyring_store: Arc = Arc::new(DefaultKeyringStore); + create_auth_storage_with_keyring_store(codex_home, mode, keyring_store) +} + +fn create_auth_storage_with_keyring_store( + codex_home: PathBuf, + mode: AuthCredentialsStoreMode, + keyring_store: Arc, +) -> Arc { + match mode { + AuthCredentialsStoreMode::File => Arc::new(FileAuthStorage::new(codex_home)), + AuthCredentialsStoreMode::Keyring => { + Arc::new(KeyringAuthStorage::new(codex_home, keyring_store)) + } + AuthCredentialsStoreMode::Auto => Arc::new(AutoAuthStorage::new(codex_home, keyring_store)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::token_data::IdTokenInfo; + use anyhow::Context; + use base64::Engine; + use pretty_assertions::assert_eq; + use serde_json::json; + use tempfile::tempdir; + + use codex_keyring_store::tests::MockKeyringStore; + use keyring::Error as KeyringError; + + #[tokio::test] + async fn file_storage_load_returns_auth_dot_json() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let auth_dot_json = AuthDotJson { + openai_api_key: Some("test-key".to_string()), + tokens: None, + last_refresh: Some(Utc::now()), + }; + + storage + .save(&auth_dot_json) + .context("failed to save auth file")?; + + let loaded = storage.load().context("failed to load auth file")?; + assert_eq!(Some(auth_dot_json), loaded); + Ok(()) + } + + #[tokio::test] + async fn file_storage_save_persists_auth_dot_json() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let auth_dot_json = AuthDotJson { + openai_api_key: Some("test-key".to_string()), + tokens: None, + last_refresh: Some(Utc::now()), + }; + + let file = get_auth_file(codex_home.path()); + storage + .save(&auth_dot_json) + .context("failed to save auth file")?; + + let same_auth_dot_json = storage + .try_read_auth_json(&file) + .context("failed to read auth file after save")?; + assert_eq!(auth_dot_json, same_auth_dot_json); + Ok(()) + } + + #[test] + fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> { + let dir = tempdir()?; + let auth_dot_json = AuthDotJson { + openai_api_key: Some("sk-test-key".to_string()), + tokens: None, + last_refresh: None, + }; + let storage = create_auth_storage(dir.path().to_path_buf(), AuthCredentialsStoreMode::File); + storage.save(&auth_dot_json)?; + assert!(dir.path().join("auth.json").exists()); + let storage = FileAuthStorage::new(dir.path().to_path_buf()); + let removed = storage.delete()?; + assert!(removed); + assert!(!dir.path().join("auth.json").exists()); + Ok(()) + } + + fn seed_keyring_and_fallback_auth_file_for_delete( + mock_keyring: &MockKeyringStore, + codex_home: &Path, + compute_key: F, + ) -> anyhow::Result<(String, PathBuf)> + where + F: FnOnce() -> std::io::Result, + { + let key = compute_key()?; + mock_keyring.save(KEYRING_SERVICE, &key, "{}")?; + let auth_file = get_auth_file(codex_home); + std::fs::write(&auth_file, "stale")?; + Ok((key, auth_file)) + } + + fn seed_keyring_with_auth( + mock_keyring: &MockKeyringStore, + compute_key: F, + auth: &AuthDotJson, + ) -> anyhow::Result<()> + where + F: FnOnce() -> std::io::Result, + { + let key = compute_key()?; + let serialized = serde_json::to_string(auth)?; + mock_keyring.save(KEYRING_SERVICE, &key, &serialized)?; + Ok(()) + } + + fn assert_keyring_saved_auth_and_removed_fallback( + mock_keyring: &MockKeyringStore, + key: &str, + codex_home: &Path, + expected: &AuthDotJson, + ) { + let saved_value = mock_keyring + .saved_value(key) + .expect("keyring entry should exist"); + let expected_serialized = serde_json::to_string(expected).expect("serialize expected auth"); + assert_eq!(saved_value, expected_serialized); + let auth_file = get_auth_file(codex_home); + assert!( + !auth_file.exists(), + "fallback auth.json should be removed after keyring save" + ); + } + + fn id_token_with_prefix(prefix: &str) -> IdTokenInfo { + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = json!({ + "email": format!("{prefix}@example.com"), + "https://api.openai.com/auth": { + "chatgpt_account_id": format!("{prefix}-account"), + }, + }); + let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header")); + let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload")); + let signature_b64 = encode(b"sig"); + let fake_jwt = format!("{header_b64}.{payload_b64}.{signature_b64}"); + + crate::token_data::parse_id_token(&fake_jwt).expect("fake JWT should parse") + } + + fn auth_with_prefix(prefix: &str) -> AuthDotJson { + AuthDotJson { + openai_api_key: Some(format!("{prefix}-api-key")), + tokens: Some(TokenData { + id_token: id_token_with_prefix(prefix), + access_token: format!("{prefix}-access"), + refresh_token: format!("{prefix}-refresh"), + account_id: Some(format!("{prefix}-account-id")), + }), + last_refresh: None, + } + } + + #[test] + fn keyring_auth_storage_load_returns_deserialized_auth() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let mock_keyring = MockKeyringStore::default(); + let storage = KeyringAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring.clone()), + ); + let expected = AuthDotJson { + openai_api_key: Some("sk-test".to_string()), + tokens: None, + last_refresh: None, + }; + seed_keyring_with_auth( + &mock_keyring, + || compute_store_key(codex_home.path()), + &expected, + )?; + + let loaded = storage.load()?; + assert_eq!(Some(expected), loaded); + Ok(()) + } + + #[test] + fn keyring_auth_storage_compute_store_key_for_home_directory() -> anyhow::Result<()> { + let codex_home = PathBuf::from("~/.codex"); + + let key = compute_store_key(codex_home.as_path())?; + + assert_eq!(key, "cli|940db7b1d0e4eb40"); + Ok(()) + } + + #[test] + fn keyring_auth_storage_save_persists_and_removes_fallback_file() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let mock_keyring = MockKeyringStore::default(); + let storage = KeyringAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring.clone()), + ); + let auth_file = get_auth_file(codex_home.path()); + std::fs::write(&auth_file, "stale")?; + let auth = AuthDotJson { + openai_api_key: None, + tokens: Some(TokenData { + id_token: Default::default(), + access_token: "access".to_string(), + refresh_token: "refresh".to_string(), + account_id: Some("account".to_string()), + }), + last_refresh: Some(Utc::now()), + }; + + storage.save(&auth)?; + + let key = compute_store_key(codex_home.path())?; + assert_keyring_saved_auth_and_removed_fallback( + &mock_keyring, + &key, + codex_home.path(), + &auth, + ); + Ok(()) + } + + #[test] + fn keyring_auth_storage_delete_removes_keyring_and_file() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let mock_keyring = MockKeyringStore::default(); + let storage = KeyringAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring.clone()), + ); + let (key, auth_file) = seed_keyring_and_fallback_auth_file_for_delete( + &mock_keyring, + codex_home.path(), + || compute_store_key(codex_home.path()), + )?; + + let removed = storage.delete()?; + + assert!(removed, "delete should report removal"); + assert!( + !mock_keyring.contains(&key), + "keyring entry should be removed" + ); + assert!( + !auth_file.exists(), + "fallback auth.json should be removed after keyring delete" + ); + Ok(()) + } + + #[test] + fn auto_auth_storage_load_prefers_keyring_value() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let mock_keyring = MockKeyringStore::default(); + let storage = AutoAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring.clone()), + ); + let keyring_auth = auth_with_prefix("keyring"); + seed_keyring_with_auth( + &mock_keyring, + || compute_store_key(codex_home.path()), + &keyring_auth, + )?; + + let file_auth = auth_with_prefix("file"); + storage.file_storage.save(&file_auth)?; + + let loaded = storage.load()?; + assert_eq!(loaded, Some(keyring_auth)); + Ok(()) + } + + #[test] + fn auto_auth_storage_load_uses_file_when_keyring_empty() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let mock_keyring = MockKeyringStore::default(); + let storage = AutoAuthStorage::new(codex_home.path().to_path_buf(), Arc::new(mock_keyring)); + + let expected = auth_with_prefix("file-only"); + storage.file_storage.save(&expected)?; + + let loaded = storage.load()?; + assert_eq!(loaded, Some(expected)); + Ok(()) + } + + #[test] + fn auto_auth_storage_load_falls_back_when_keyring_errors() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let mock_keyring = MockKeyringStore::default(); + let storage = AutoAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring.clone()), + ); + let key = compute_store_key(codex_home.path())?; + mock_keyring.set_error(&key, KeyringError::Invalid("error".into(), "load".into())); + + let expected = auth_with_prefix("fallback"); + storage.file_storage.save(&expected)?; + + let loaded = storage.load()?; + assert_eq!(loaded, Some(expected)); + Ok(()) + } + + #[test] + fn auto_auth_storage_save_prefers_keyring() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let mock_keyring = MockKeyringStore::default(); + let storage = AutoAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring.clone()), + ); + let key = compute_store_key(codex_home.path())?; + + let stale = auth_with_prefix("stale"); + storage.file_storage.save(&stale)?; + + let expected = auth_with_prefix("to-save"); + storage.save(&expected)?; + + assert_keyring_saved_auth_and_removed_fallback( + &mock_keyring, + &key, + codex_home.path(), + &expected, + ); + Ok(()) + } + + #[test] + fn auto_auth_storage_save_falls_back_when_keyring_errors() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let mock_keyring = MockKeyringStore::default(); + let storage = AutoAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring.clone()), + ); + let key = compute_store_key(codex_home.path())?; + mock_keyring.set_error(&key, KeyringError::Invalid("error".into(), "save".into())); + + let auth = auth_with_prefix("fallback"); + storage.save(&auth)?; + + let auth_file = get_auth_file(codex_home.path()); + assert!( + auth_file.exists(), + "fallback auth.json should be created when keyring save fails" + ); + let saved = storage + .file_storage + .load()? + .context("fallback auth should exist")?; + assert_eq!(saved, auth); + assert!( + mock_keyring.saved_value(&key).is_none(), + "keyring should not contain value when save fails" + ); + Ok(()) + } + + #[test] + fn auto_auth_storage_delete_removes_keyring_and_file() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let mock_keyring = MockKeyringStore::default(); + let storage = AutoAuthStorage::new( + codex_home.path().to_path_buf(), + Arc::new(mock_keyring.clone()), + ); + let (key, auth_file) = seed_keyring_and_fallback_auth_file_for_delete( + &mock_keyring, + codex_home.path(), + || compute_store_key(codex_home.path()), + )?; + + let removed = storage.delete()?; + + assert!(removed, "delete should report removal"); + assert!( + !mock_keyring.contains(&key), + "keyring entry should be removed" + ); + assert!( + !auth_file.exists(), + "fallback auth.json should be removed after delete" + ); + Ok(()) + } +} diff --git a/codex-rs/core/src/bash.rs b/codex-rs/core/src/bash.rs index c87f2764b1..07fc125f16 100644 --- a/codex-rs/core/src/bash.rs +++ b/codex-rs/core/src/bash.rs @@ -5,13 +5,13 @@ use tree_sitter_bash::LANGUAGE as BASH; /// Parse the provided bash source using tree-sitter-bash, returning a Tree on /// success or None if parsing failed. -pub fn try_parse_bash(bash_lc_arg: &str) -> Option { +pub fn try_parse_shell(shell_lc_arg: &str) -> Option { let lang = BASH.into(); let mut parser = Parser::new(); #[expect(clippy::expect_used)] parser.set_language(&lang).expect("load bash grammar"); let old_tree: Option<&Tree> = None; - parser.parse(bash_lc_arg, old_tree) + parser.parse(shell_lc_arg, old_tree) } /// Parse a script which may contain multiple simple commands joined only by @@ -88,18 +88,19 @@ pub fn try_parse_word_only_commands_sequence(tree: &Tree, src: &str) -> Option Option>> { - let [bash, flag, script] = command else { +/// Returns the sequence of plain commands within a `bash -lc "..."` or +/// `zsh -lc "..."` invocation when the script only contains word-only commands +/// joined by safe operators. +pub fn parse_shell_lc_plain_commands(command: &[String]) -> Option>> { + let [shell, flag, script] = command else { return None; }; - if bash != "bash" || flag != "-lc" { + if flag != "-lc" || !(shell == "bash" || shell == "zsh") { return None; } - let tree = try_parse_bash(script)?; + let tree = try_parse_shell(script)?; try_parse_word_only_commands_sequence(&tree, script) } @@ -154,7 +155,7 @@ mod tests { use super::*; fn parse_seq(src: &str) -> Option>> { - let tree = try_parse_bash(src)?; + let tree = try_parse_shell(src)?; try_parse_word_only_commands_sequence(&tree, src) } @@ -234,4 +235,11 @@ mod tests { fn rejects_trailing_operator_parse_error() { assert!(parse_seq("ls &&").is_none()); } + + #[test] + fn parse_zsh_lc_plain_commands() { + let command = vec!["zsh".to_string(), "-lc".to_string(), "ls".to_string()]; + let parsed = parse_shell_lc_plain_commands(&command).unwrap(); + assert_eq!(parsed, vec![vec!["ls".to_string()]]); + } } diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index d6f394fb86..abb27d9b55 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -4,18 +4,24 @@ use crate::ModelProviderInfo; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::client_common::ResponseStream; +use crate::default_client::CodexHttpClient; use crate::error::CodexErr; +use crate::error::ConnectionFailedError; +use crate::error::ResponseStreamFailed; use crate::error::Result; use crate::error::RetryLimitReachedError; use crate::error::UnexpectedResponseError; use crate::model_family::ModelFamily; -use crate::openai_tools::create_tools_json_for_chat_completions_api; +use crate::tools::spec::create_tools_json_for_chat_completions_api; use crate::util::backoff; use bytes::Bytes; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::models::ContentItem; +use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use eventsource_stream::Eventsource; use futures::Stream; use futures::StreamExt; @@ -34,9 +40,10 @@ use tracing::trace; pub(crate) async fn stream_chat_completions( prompt: &Prompt, model_family: &ModelFamily, - client: &reqwest::Client, + client: &CodexHttpClient, provider: &ModelProviderInfo, otel_event_manager: &OtelEventManager, + session_source: &SessionSource, ) -> Result { if prompt.output_schema.is_some() { return Err(CodexErr::UnsupportedOperation( @@ -73,6 +80,7 @@ pub(crate) async fn stream_chat_completions( ResponseItem::CustomToolCall { .. } => {} ResponseItem::CustomToolCallOutput { .. } => {} ResponseItem::WebSearchCall { .. } => {} + ResponseItem::GhostSnapshot { .. } => {} } } @@ -102,10 +110,10 @@ pub(crate) async fn stream_chat_completions( } = item { let mut text = String::new(); - for c in items { - match c { - ReasoningItemContent::ReasoningText { text: t } - | ReasoningItemContent::Text { text: t } => text.push_str(t), + for entry in items { + match entry { + ReasoningItemContent::ReasoningText { text: segment } + | ReasoningItemContent::Text { text: segment } => text.push_str(segment), } } if text.trim().is_empty() { @@ -155,16 +163,26 @@ pub(crate) async fn stream_chat_completions( for (idx, item) in input.iter().enumerate() { match item { ResponseItem::Message { role, content, .. } => { + // Build content either as a plain string (typical for assistant text) + // or as an array of content items when images are present (user/tool multimodal). let mut text = String::new(); + let mut items: Vec = Vec::new(); + let mut saw_image = false; + for c in content { match c { ContentItem::InputText { text: t } | ContentItem::OutputText { text: t } => { text.push_str(t); + items.push(json!({"type":"text","text": t})); + } + ContentItem::InputImage { image_url } => { + saw_image = true; + items.push(json!({"type":"image_url","image_url": {"url": image_url}})); } - _ => {} } } + // Skip exact-duplicate assistant messages. if role == "assistant" { if let Some(prev) = &last_assistant_text @@ -175,7 +193,17 @@ pub(crate) async fn stream_chat_completions( last_assistant_text = Some(text.clone()); } - let mut msg = json!({"role": role, "content": text}); + // For assistant messages, always send a plain string for compatibility. + // For user messages, if an image is present, send an array of content items. + let content_value = if role == "assistant" { + json!(text) + } else if saw_image { + json!(items) + } else { + json!(text) + }; + + let mut msg = json!({"role": role, "content": content_value}); if role == "assistant" && let Some(reasoning) = reasoning_by_anchor_index.get(&idx) && let Some(obj) = msg.as_object_mut() @@ -234,10 +262,29 @@ pub(crate) async fn stream_chat_completions( messages.push(msg); } ResponseItem::FunctionCallOutput { call_id, output } => { + // Prefer structured content items when available (e.g., images) + // otherwise fall back to the legacy plain-string content. + let content_value = if let Some(items) = &output.content_items { + let mapped: Vec = items + .iter() + .map(|it| match it { + FunctionCallOutputContentItem::InputText { text } => { + json!({"type":"text","text": text}) + } + FunctionCallOutputContentItem::InputImage { image_url } => { + json!({"type":"image_url","image_url": {"url": image_url}}) + } + }) + .collect(); + json!(mapped) + } else { + json!(output.content) + }; + messages.push(json!({ "role": "tool", "tool_call_id": call_id, - "content": output.content, + "content": content_value, })); } ResponseItem::CustomToolCall { @@ -267,6 +314,10 @@ pub(crate) async fn stream_chat_completions( "content": output, })); } + ResponseItem::GhostSnapshot { .. } => { + // Ghost snapshots annotate history but are not sent to the model. + continue; + } ResponseItem::Reasoning { .. } | ResponseItem::WebSearchCall { .. } | ResponseItem::Other => { @@ -295,7 +346,20 @@ pub(crate) async fn stream_chat_completions( loop { attempt += 1; - let req_builder = provider.create_request_builder(client, &None).await?; + let mut req_builder = provider.create_request_builder(client, &None).await?; + + // Include subagent header only for subagent sessions. + if let SessionSource::SubAgent(sub) = session_source.clone() { + let subagent = if let SubAgentSource::Other(label) = sub { + label + } else { + serde_json::to_value(&sub) + .ok() + .and_then(|v| v.as_str().map(std::string::ToString::to_string)) + .unwrap_or_else(|| "other".to_string()) + }; + req_builder = req_builder.header("x-openai-subagent", subagent); + } let res = otel_event_manager .log_request(attempt, || { @@ -309,7 +373,12 @@ pub(crate) async fn stream_chat_completions( match res { Ok(resp) if resp.status().is_success() => { let (tx_event, rx_event) = mpsc::channel::>(1600); - let stream = resp.bytes_stream().map_err(CodexErr::Reqwest); + let stream = resp.bytes_stream().map_err(|e| { + CodexErr::ResponseStreamFailed(ResponseStreamFailed { + source: e, + request_id: None, + }) + }); tokio::spawn(process_chat_sse( stream, tx_event, @@ -349,7 +418,9 @@ pub(crate) async fn stream_chat_completions( } Err(e) => { if attempt > max_retries { - return Err(e.into()); + return Err(CodexErr::ConnectionFailed(ConnectionFailedError { + source: e, + })); } let delay = backoff(attempt); tokio::time::sleep(delay).await; @@ -358,6 +429,61 @@ pub(crate) async fn stream_chat_completions( } } +async fn append_assistant_text( + tx_event: &mpsc::Sender>, + assistant_item: &mut Option, + text: String, +) { + if assistant_item.is_none() { + let item = ResponseItem::Message { + id: None, + role: "assistant".to_string(), + content: vec![], + }; + *assistant_item = Some(item.clone()); + let _ = tx_event + .send(Ok(ResponseEvent::OutputItemAdded(item))) + .await; + } + + if let Some(ResponseItem::Message { content, .. }) = assistant_item { + content.push(ContentItem::OutputText { text: text.clone() }); + let _ = tx_event + .send(Ok(ResponseEvent::OutputTextDelta(text.clone()))) + .await; + } +} + +async fn append_reasoning_text( + tx_event: &mpsc::Sender>, + reasoning_item: &mut Option, + text: String, +) { + if reasoning_item.is_none() { + let item = ResponseItem::Reasoning { + id: String::new(), + summary: Vec::new(), + content: Some(vec![]), + encrypted_content: None, + }; + *reasoning_item = Some(item.clone()); + let _ = tx_event + .send(Ok(ResponseEvent::OutputItemAdded(item))) + .await; + } + + if let Some(ResponseItem::Reasoning { + content: Some(content), + .. + }) = reasoning_item + { + content.push(ReasoningItemContent::ReasoningText { text: text.clone() }); + + let _ = tx_event + .send(Ok(ResponseEvent::ReasoningContentDelta(text.clone()))) + .await; + } +} /// Lightweight SSE processor for the Chat Completions streaming format. The /// output is mapped onto Codex's internal [`ResponseEvent`] so that the rest /// of the pipeline can stay agnostic of the underlying wire format. @@ -385,8 +511,8 @@ async fn process_chat_sse( } let mut fn_call_state = FunctionCallState::default(); - let mut assistant_text = String::new(); - let mut reasoning_text = String::new(); + let mut assistant_item: Option = None; + let mut reasoning_item: Option = None; loop { let start = std::time::Instant::now(); @@ -427,26 +553,11 @@ async fn process_chat_sse( if sse.data.trim() == "[DONE]" { // Emit any finalized items before closing so downstream consumers receive // terminal events for both assistant content and raw reasoning. - if !assistant_text.is_empty() { - let item = ResponseItem::Message { - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: std::mem::take(&mut assistant_text), - }], - id: None, - }; + if let Some(item) = assistant_item { let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await; } - if !reasoning_text.is_empty() { - let item = ResponseItem::Reasoning { - id: String::new(), - summary: Vec::new(), - content: Some(vec![ReasoningItemContent::ReasoningText { - text: std::mem::take(&mut reasoning_text), - }]), - encrypted_content: None, - }; + if let Some(item) = reasoning_item { let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await; } @@ -476,10 +587,7 @@ async fn process_chat_sse( .and_then(|c| c.as_str()) && !content.is_empty() { - assistant_text.push_str(content); - let _ = tx_event - .send(Ok(ResponseEvent::OutputTextDelta(content.to_string()))) - .await; + append_assistant_text(&tx_event, &mut assistant_item, content.to_string()).await; } // Forward any reasoning/thinking deltas if present. @@ -509,10 +617,7 @@ async fn process_chat_sse( if let Some(reasoning) = maybe_text { // Accumulate so we can emit a terminal Reasoning item at the end. - reasoning_text.push_str(&reasoning); - let _ = tx_event - .send(Ok(ResponseEvent::ReasoningContentDelta(reasoning))) - .await; + append_reasoning_text(&tx_event, &mut reasoning_item, reasoning).await; } } @@ -522,10 +627,7 @@ async fn process_chat_sse( // Accept either a plain string or an object with { text | content } if let Some(s) = message_reasoning.as_str() { if !s.is_empty() { - reasoning_text.push_str(s); - let _ = tx_event - .send(Ok(ResponseEvent::ReasoningContentDelta(s.to_string()))) - .await; + append_reasoning_text(&tx_event, &mut reasoning_item, s.to_string()).await; } } else if let Some(obj) = message_reasoning.as_object() && let Some(s) = obj @@ -534,10 +636,7 @@ async fn process_chat_sse( .or_else(|| obj.get("content").and_then(|v| v.as_str())) && !s.is_empty() { - reasoning_text.push_str(s); - let _ = tx_event - .send(Ok(ResponseEvent::ReasoningContentDelta(s.to_string()))) - .await; + append_reasoning_text(&tx_event, &mut reasoning_item, s.to_string()).await; } } @@ -575,15 +674,7 @@ async fn process_chat_sse( "tool_calls" if fn_call_state.active => { // First, flush the terminal raw reasoning so UIs can finalize // the reasoning stream before any exec/tool events begin. - if !reasoning_text.is_empty() { - let item = ResponseItem::Reasoning { - id: String::new(), - summary: Vec::new(), - content: Some(vec![ReasoningItemContent::ReasoningText { - text: std::mem::take(&mut reasoning_text), - }]), - encrypted_content: None, - }; + if let Some(item) = reasoning_item.take() { let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await; } @@ -600,26 +691,11 @@ async fn process_chat_sse( "stop" => { // Regular turn without tool-call. Emit the final assistant message // as a single OutputItemDone so non-delta consumers see the result. - if !assistant_text.is_empty() { - let item = ResponseItem::Message { - role: "assistant".to_string(), - content: vec![ContentItem::OutputText { - text: std::mem::take(&mut assistant_text), - }], - id: None, - }; + if let Some(item) = assistant_item.take() { let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await; } // Also emit a terminal Reasoning item so UIs can finalize raw reasoning. - if !reasoning_text.is_empty() { - let item = ResponseItem::Reasoning { - id: String::new(), - summary: Vec::new(), - content: Some(vec![ReasoningItemContent::ReasoningText { - text: std::mem::take(&mut reasoning_text), - }]), - encrypted_content: None, - }; + if let Some(item) = reasoning_item.take() { let _ = tx_event.send(Ok(ResponseEvent::OutputItemDone(item))).await; } } @@ -838,8 +914,8 @@ where Poll::Ready(Some(Ok(ResponseEvent::ReasoningSummaryPartAdded))) => { continue; } - Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallBegin { call_id }))) => { - return Poll::Ready(Some(Ok(ResponseEvent::WebSearchCallBegin { call_id }))); + Poll::Ready(Some(Ok(ResponseEvent::OutputItemAdded(item)))) => { + return Poll::Ready(Some(Ok(ResponseEvent::OutputItemAdded(item)))); } } } diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index f59f30c5cf..3a0bcb9b54 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1,15 +1,19 @@ use std::io::BufRead; use std::path::Path; +use std::sync::Arc; use std::sync::OnceLock; use std::time::Duration; -use crate::AuthManager; -use crate::auth::CodexAuth; -use crate::error::RetryLimitReachedError; -use crate::error::UnexpectedResponseError; use bytes::Bytes; +use chrono::DateTime; +use chrono::Utc; use codex_app_server_protocol::AuthMode; +use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::ConversationId; +use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; +use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::SessionSource; use eventsource_stream::Eventsource; use futures::prelude::*; use regex_lite::Regex; @@ -25,6 +29,9 @@ use tracing::debug; use tracing::trace; use tracing::warn; +use crate::AuthManager; +use crate::auth::CodexAuth; +use crate::auth::RefreshTokenError; use crate::chat_completions::AggregateStreamExt; use crate::chat_completions::stream_chat_completions; use crate::client_common::Prompt; @@ -34,27 +41,26 @@ use crate::client_common::ResponsesApiRequest; use crate::client_common::create_reasoning_param_for_request; use crate::client_common::create_text_param_for_request; use crate::config::Config; +use crate::default_client::CodexHttpClient; use crate::default_client::create_client; use crate::error::CodexErr; +use crate::error::ConnectionFailedError; +use crate::error::ResponseStreamFailed; use crate::error::Result; +use crate::error::RetryLimitReachedError; +use crate::error::UnexpectedResponseError; use crate::error::UsageLimitReachedError; use crate::flags::CODEX_RS_SSE_FIXTURE; use crate::model_family::ModelFamily; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::WireApi; use crate::openai_model_info::get_model_info; -use crate::openai_tools::create_tools_json_for_responses_api; use crate::protocol::RateLimitSnapshot; use crate::protocol::RateLimitWindow; use crate::protocol::TokenUsage; -use crate::state::TaskKind; use crate::token_data::PlanType; +use crate::tools::spec::create_tools_json_for_responses_api; use crate::util::backoff; -use codex_otel::otel_event_manager::OtelEventManager; -use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; -use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; -use codex_protocol::models::ResponseItem; -use std::sync::Arc; #[derive(Debug, Deserialize)] struct ErrorResponse { @@ -69,7 +75,7 @@ struct Error { // Optional fields available on "usage_limit_reached" and "usage_not_included" errors plan_type: Option, - resets_in_seconds: Option, + resets_at: Option, } #[derive(Debug, Clone)] @@ -77,13 +83,15 @@ pub struct ModelClient { config: Arc, auth_manager: Option>, otel_event_manager: OtelEventManager, - client: reqwest::Client, + client: CodexHttpClient, provider: ModelProviderInfo, conversation_id: ConversationId, effort: Option, summary: ReasoningSummaryConfig, + session_source: SessionSource, } +#[allow(clippy::too_many_arguments)] impl ModelClient { pub fn new( config: Arc, @@ -93,6 +101,7 @@ impl ModelClient { effort: Option, summary: ReasoningSummaryConfig, conversation_id: ConversationId, + session_source: SessionSource, ) -> Self { let client = create_client(); @@ -105,13 +114,16 @@ impl ModelClient { conversation_id, effort, summary, + session_source, } } - pub fn get_model_context_window(&self) -> Option { + pub fn get_model_context_window(&self) -> Option { + let pct = self.config.model_family.effective_context_window_percent; self.config .model_context_window .or_else(|| get_model_info(&self.config.model_family).map(|info| info.context_window)) + .map(|w| w.saturating_mul(pct) / 100) } pub fn get_auto_compact_token_limit(&self) -> Option { @@ -120,20 +132,17 @@ impl ModelClient { }) } - /// Dispatches to either the Responses or Chat implementation depending on - /// the provider config. Public callers always invoke `stream()` – the - /// specialised helpers are private to avoid accidental misuse. - pub async fn stream(&self, prompt: &Prompt) -> Result { - self.stream_with_task_kind(prompt, TaskKind::Regular).await + pub fn config(&self) -> Arc { + Arc::clone(&self.config) } - pub(crate) async fn stream_with_task_kind( - &self, - prompt: &Prompt, - task_kind: TaskKind, - ) -> Result { + pub fn provider(&self) -> &ModelProviderInfo { + &self.provider + } + + pub async fn stream(&self, prompt: &Prompt) -> Result { match self.provider.wire_api { - WireApi::Responses => self.stream_responses(prompt, task_kind).await, + WireApi::Responses => self.stream_responses(prompt).await, WireApi::Chat => { // Create the raw streaming connection first. let response_stream = stream_chat_completions( @@ -142,6 +151,7 @@ impl ModelClient { &self.client, &self.provider, &self.otel_event_manager, + &self.session_source, ) .await?; @@ -174,11 +184,7 @@ impl ModelClient { } /// Implementation for the OpenAI *Responses* experimental API. - async fn stream_responses( - &self, - prompt: &Prompt, - task_kind: TaskKind, - ) -> Result { + async fn stream_responses(&self, prompt: &Prompt) -> Result { if let Some(path) = &*CODEX_RS_SSE_FIXTURE { // short circuit for tests warn!(path, "Streaming from fixture"); @@ -208,18 +214,16 @@ impl ModelClient { let input_with_instructions = prompt.get_formatted_input(); - let verbosity = match &self.config.model_family.family { - family if family == "gpt-5" => self.config.model_verbosity, - _ => { - if self.config.model_verbosity.is_some() { - warn!( - "model_verbosity is set but ignored for non-gpt-5 model family: {}", - self.config.model_family.family - ); - } - - None + let verbosity = if self.config.model_family.support_verbosity { + self.config.model_verbosity + } else { + if self.config.model_verbosity.is_some() { + warn!( + "model_verbosity is set but ignored as the model does not support verbosity: {}", + self.config.model_family.family + ); } + None }; // Only include `text.verbosity` for GPT-5 family models @@ -257,7 +261,7 @@ impl ModelClient { let max_attempts = self.provider.request_max_retries(); for attempt in 0..=max_attempts { match self - .attempt_stream_responses(attempt, &payload_json, &auth_manager, task_kind) + .attempt_stream_responses(attempt, &payload_json, &auth_manager) .await { Ok(stream) => { @@ -285,7 +289,6 @@ impl ModelClient { attempt: u64, payload_json: &Value, auth_manager: &Option>, - task_kind: TaskKind, ) -> std::result::Result { // Always fetch the latest auth in case a prior attempt refreshed the token. let auth = auth_manager.as_ref().and_then(|m| m.auth()); @@ -294,6 +297,7 @@ impl ModelClient { "POST to {}: {:?}", self.provider.get_full_url(&auth), serde_json::to_string(payload_json) + .unwrap_or("".to_string()) ); let mut req_builder = self @@ -302,13 +306,24 @@ impl ModelClient { .await .map_err(StreamAttemptError::Fatal)?; + // Include subagent header only for subagent sessions. + if let SessionSource::SubAgent(sub) = &self.session_source { + let subagent = if let crate::protocol::SubAgentSource::Other(label) = sub { + label.clone() + } else { + serde_json::to_value(sub) + .ok() + .and_then(|v| v.as_str().map(std::string::ToString::to_string)) + .unwrap_or_else(|| "other".to_string()) + }; + req_builder = req_builder.header("x-openai-subagent", subagent); + } + req_builder = req_builder - .header("OpenAI-Beta", "responses=experimental") // Send session_id for compatibility. .header("conversation_id", self.conversation_id.to_string()) .header("session_id", self.conversation_id.to_string()) .header(reqwest::header::ACCEPT, "text/event-stream") - .header("Codex-Task-Type", task_kind.header_value()) .json(payload_json); if let Some(auth) = auth.as_ref() @@ -329,12 +344,6 @@ impl ModelClient { .headers() .get("cf-ray") .map(|v| v.to_str().unwrap_or_default().to_string()); - - trace!( - "Response status: {}, cf-ray: {:?}", - resp.status(), - request_id - ); } match res { @@ -351,7 +360,12 @@ impl ModelClient { } // spawn task to process SSE - let stream = resp.bytes_stream().map_err(CodexErr::Reqwest); + let stream = resp.bytes_stream().map_err(move |e| { + CodexErr::ResponseStreamFailed(ResponseStreamFailed { + source: e, + request_id: request_id.clone(), + }) + }); tokio::spawn(process_sse( stream, tx_event, @@ -374,9 +388,19 @@ impl ModelClient { if status == StatusCode::UNAUTHORIZED && let Some(manager) = auth_manager.as_ref() - && manager.auth().is_some() + && let Some(auth) = auth.as_ref() + && auth.mode == AuthMode::ChatGPT + && let Err(err) = manager.refresh_token().await { - let _ = manager.refresh_token().await; + let stream_error = match err { + RefreshTokenError::Permanent(failed) => { + StreamAttemptError::Fatal(CodexErr::RefreshTokenFailed(failed)) + } + RefreshTokenError::Transient(other) => { + StreamAttemptError::RetryableTransportError(CodexErr::Io(other)) + } + }; + return Err(stream_error); } // The OpenAI Responses endpoint returns structured JSON bodies even for 4xx/5xx @@ -412,15 +436,19 @@ impl ModelClient { let plan_type = error .plan_type .or_else(|| auth.as_ref().and_then(CodexAuth::get_plan_type)); - let resets_in_seconds = error.resets_in_seconds; + let resets_at = error + .resets_at + .and_then(|seconds| DateTime::::from_timestamp(seconds, 0)); let codex_err = CodexErr::UsageLimitReached(UsageLimitReachedError { plan_type, - resets_in_seconds, + resets_at, rate_limits: rate_limit_snapshot, }); return Err(StreamAttemptError::Fatal(codex_err)); } else if error.r#type.as_deref() == Some("usage_not_included") { return Err(StreamAttemptError::Fatal(CodexErr::UsageNotIncluded)); + } else if is_quota_exceeded_error(&error) { + return Err(StreamAttemptError::Fatal(CodexErr::QuotaExceeded)); } } } @@ -431,7 +459,9 @@ impl ModelClient { request_id, }) } - Err(e) => Err(StreamAttemptError::RetryableTransportError(e.into())), + Err(e) => Err(StreamAttemptError::RetryableTransportError( + CodexErr::ConnectionFailed(ConnectionFailedError { source: e }), + )), } } @@ -443,6 +473,10 @@ impl ModelClient { self.otel_event_manager.clone() } + pub fn get_session_source(&self) -> SessionSource { + self.session_source.clone() + } + /// Returns the currently configured model slug. pub fn get_model(&self) -> String { self.config.model.clone() @@ -529,11 +563,11 @@ struct ResponseCompleted { #[derive(Debug, Deserialize)] struct ResponseCompletedUsage { - input_tokens: u64, + input_tokens: i64, input_tokens_details: Option, - output_tokens: u64, + output_tokens: i64, output_tokens_details: Option, - total_tokens: u64, + total_tokens: i64, } impl From for TokenUsage { @@ -556,12 +590,12 @@ impl From for TokenUsage { #[derive(Debug, Deserialize)] struct ResponseCompletedInputTokensDetails { - cached_tokens: u64, + cached_tokens: i64, } #[derive(Debug, Deserialize)] struct ResponseCompletedOutputTokensDetails { - reasoning_tokens: u64, + reasoning_tokens: i64, } fn attach_item_ids(payload_json: &mut Value, original_items: &[ResponseItem]) { @@ -596,14 +630,14 @@ fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option { headers, "x-codex-primary-used-percent", "x-codex-primary-window-minutes", - "x-codex-primary-reset-after-seconds", + "x-codex-primary-reset-at", ); let secondary = parse_rate_limit_window( headers, "x-codex-secondary-used-percent", "x-codex-secondary-window-minutes", - "x-codex-secondary-reset-after-seconds", + "x-codex-secondary-reset-at", ); Some(RateLimitSnapshot { primary, secondary }) @@ -613,22 +647,22 @@ fn parse_rate_limit_window( headers: &HeaderMap, used_percent_header: &str, window_minutes_header: &str, - resets_header: &str, + resets_at_header: &str, ) -> Option { let used_percent: Option = parse_header_f64(headers, used_percent_header); used_percent.and_then(|used_percent| { - let window_minutes = parse_header_u64(headers, window_minutes_header); - let resets_in_seconds = parse_header_u64(headers, resets_header); + let window_minutes = parse_header_i64(headers, window_minutes_header); + let resets_at = parse_header_i64(headers, resets_at_header); let has_data = used_percent != 0.0 || window_minutes.is_some_and(|minutes| minutes != 0) - || resets_in_seconds.is_some_and(|seconds| seconds != 0); + || resets_at.is_some(); has_data.then_some(RateLimitWindow { used_percent, window_minutes, - resets_in_seconds, + resets_at, }) }) } @@ -640,8 +674,8 @@ fn parse_header_f64(headers: &HeaderMap, name: &str) -> Option { .filter(|v| v.is_finite()) } -fn parse_header_u64(headers: &HeaderMap, name: &str) -> Option { - parse_header_str(headers, name)?.parse::().ok() +fn parse_header_i64(headers: &HeaderMap, name: &str) -> Option { + parse_header_str(headers, name)?.parse::().ok() } fn parse_header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { @@ -812,6 +846,8 @@ async fn process_sse( Ok(error) => { if is_context_window_error(&error) { response_error = Some(CodexErr::ContextWindowExceeded); + } else if is_quota_exceeded_error(&error) { + response_error = Some(CodexErr::QuotaExceeded); } else { let delay = try_parse_retry_after(&error); let message = error.message.clone().unwrap_or_default(); @@ -850,21 +886,15 @@ async fn process_sse( | "response.in_progress" | "response.output_text.done" => {} "response.output_item.added" => { - if let Some(item) = event.item.as_ref() { - // Detect web_search_call begin and forward a synthetic event upstream. - if let Some(ty) = item.get("type").and_then(|v| v.as_str()) - && ty == "web_search_call" - { - let call_id = item - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(); - let ev = ResponseEvent::WebSearchCallBegin { call_id }; - if tx_event.send(Ok(ev)).await.is_err() { - return; - } - } + let Some(item_val) = event.item else { continue }; + let Ok(item) = serde_json::from_value::(item_val) else { + debug!("failed to parse ResponseItem from output_item.done"); + continue; + }; + + let event = ResponseEvent::OutputItemAdded(item); + if tx_event.send(Ok(event)).await.is_err() { + return; } } "response.reasoning_summary_part.added" => { @@ -911,8 +941,10 @@ async fn stream_from_fixture( fn rate_limit_regex() -> &'static Regex { static RE: OnceLock = OnceLock::new(); + // Match both OpenAI-style messages like "Please try again in 1.898s" + // and Azure OpenAI-style messages like "Try again in 35 seconds". #[expect(clippy::unwrap_used)] - RE.get_or_init(|| Regex::new(r"Please try again in (\d+(?:\.\d+)?)(s|ms)").unwrap()) + RE.get_or_init(|| Regex::new(r"(?i)try again in\s*(\d+(?:\.\d+)?)\s*(s|ms|seconds?)").unwrap()) } fn try_parse_retry_after(err: &Error) -> Option { @@ -920,7 +952,8 @@ fn try_parse_retry_after(err: &Error) -> Option { return None; } - // parse the Please try again in 1.898s format using regex + // parse retry hints like "try again in 1.898s" or + // "Try again in 35 seconds" using regex let re = rate_limit_regex(); if let Some(message) = &err.message && let Some(captures) = re.captures(message) @@ -930,9 +963,9 @@ fn try_parse_retry_after(err: &Error) -> Option { if let (Some(value), Some(unit)) = (seconds, unit) { let value = value.as_str().parse::().ok()?; - let unit = unit.as_str(); + let unit = unit.as_str().to_ascii_lowercase(); - if unit == "s" { + if unit == "s" || unit.starts_with("second") { return Some(Duration::from_secs_f64(value)); } else if unit == "ms" { return Some(Duration::from_millis(value as u64)); @@ -946,6 +979,10 @@ fn is_context_window_error(error: &Error) -> bool { error.code.as_deref() == Some("context_length_exceeded") } +fn is_quota_exceeded_error(error: &Error) -> bool { + error.code.as_deref() == Some("insufficient_quota") +} + #[cfg(test)] mod tests { use super::*; @@ -1078,6 +1115,7 @@ mod tests { base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), env_key_instructions: None, + experimental_bearer_token: None, wire_api: WireApi::Responses, query_params: None, http_headers: None, @@ -1141,6 +1179,7 @@ mod tests { base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), env_key_instructions: None, + experimental_bearer_token: None, wire_api: WireApi::Responses, query_params: None, http_headers: None, @@ -1177,6 +1216,7 @@ mod tests { base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), env_key_instructions: None, + experimental_bearer_token: None, wire_api: WireApi::Responses, query_params: None, http_headers: None, @@ -1215,6 +1255,7 @@ mod tests { base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), env_key_instructions: None, + experimental_bearer_token: None, wire_api: WireApi::Responses, query_params: None, http_headers: None, @@ -1249,6 +1290,7 @@ mod tests { base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), env_key_instructions: None, + experimental_bearer_token: None, wire_api: WireApi::Responses, query_params: None, http_headers: None, @@ -1273,6 +1315,41 @@ mod tests { } } + #[tokio::test] + async fn quota_exceeded_error_is_fatal() { + let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_fatal_quota","object":"response","created_at":1759771626,"status":"failed","background":false,"error":{"code":"insufficient_quota","message":"You exceeded your current quota, please check your plan and billing details. For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors."},"incomplete_details":null}}"#; + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + let provider = ModelProviderInfo { + name: "test".to_string(), + base_url: Some("https://test.com".to_string()), + env_key: Some("TEST_API_KEY".to_string()), + env_key_instructions: None, + experimental_bearer_token: None, + wire_api: WireApi::Responses, + query_params: None, + http_headers: None, + env_http_headers: None, + request_max_retries: Some(0), + stream_max_retries: Some(0), + stream_idle_timeout_ms: Some(1000), + requires_openai_auth: false, + }; + + let otel_event_manager = otel_event_manager(); + + let events = collect_events(&[sse1.as_bytes()], provider, otel_event_manager).await; + + assert_eq!(events.len(), 1); + + match &events[0] { + Err(err @ CodexErr::QuotaExceeded) => { + assert_eq!(err.to_string(), CodexErr::QuotaExceeded.to_string()); + } + other => panic!("unexpected quota exceeded event: {other:?}"), + } + } + // ──────────────────────────── // Table-driven test from `main` // ──────────────────────────── @@ -1352,6 +1429,7 @@ mod tests { base_url: Some("https://test.com".to_string()), env_key: Some("TEST_API_KEY".to_string()), env_key_instructions: None, + experimental_bearer_token: None, wire_api: WireApi::Responses, query_params: None, http_headers: None, @@ -1381,7 +1459,7 @@ mod tests { message: Some("Rate limit reached for gpt-5 in organization org- on tokens per min (TPM): Limit 1, Used 1, Requested 19304. Please try again in 28ms. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()), code: Some("rate_limit_exceeded".to_string()), plan_type: None, - resets_in_seconds: None + resets_at: None }; let delay = try_parse_retry_after(&err); @@ -1395,20 +1473,33 @@ mod tests { message: Some("Rate limit reached for gpt-5 in organization on tokens per min (TPM): Limit 30000, Used 6899, Requested 24050. Please try again in 1.898s. Visit https://platform.openai.com/account/rate-limits to learn more.".to_string()), code: Some("rate_limit_exceeded".to_string()), plan_type: None, - resets_in_seconds: None + resets_at: None }; let delay = try_parse_retry_after(&err); assert_eq!(delay, Some(Duration::from_secs_f64(1.898))); } #[test] - fn error_response_deserializes_old_schema_known_plan_type_and_serializes_back() { + fn test_try_parse_retry_after_azure() { + let err = Error { + r#type: None, + message: Some("Rate limit exceeded. Try again in 35 seconds.".to_string()), + code: Some("rate_limit_exceeded".to_string()), + plan_type: None, + resets_at: None, + }; + let delay = try_parse_retry_after(&err); + assert_eq!(delay, Some(Duration::from_secs(35))); + } + + #[test] + fn error_response_deserializes_schema_known_plan_type_and_serializes_back() { use crate::token_data::KnownPlan; use crate::token_data::PlanType; - let json = r#"{"error":{"type":"usage_limit_reached","plan_type":"pro","resets_in_seconds":3600}}"#; - let resp: ErrorResponse = - serde_json::from_str(json).expect("should deserialize old schema"); + let json = + r#"{"error":{"type":"usage_limit_reached","plan_type":"pro","resets_at":1704067200}}"#; + let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize schema"); assert_matches!(resp.error.plan_type, Some(PlanType::Known(KnownPlan::Pro))); @@ -1417,13 +1508,12 @@ mod tests { } #[test] - fn error_response_deserializes_old_schema_unknown_plan_type_and_serializes_back() { + fn error_response_deserializes_schema_unknown_plan_type_and_serializes_back() { use crate::token_data::PlanType; let json = - r#"{"error":{"type":"usage_limit_reached","plan_type":"vip","resets_in_seconds":60}}"#; - let resp: ErrorResponse = - serde_json::from_str(json).expect("should deserialize old schema"); + r#"{"error":{"type":"usage_limit_reached","plan_type":"vip","resets_at":1704067260}}"#; + let resp: ErrorResponse = serde_json::from_str(json).expect("should deserialize schema"); assert_matches!(resp.error.plan_type, Some(PlanType::Unknown(ref s)) if s == "vip"); diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index d30da5192a..2ac02f5f66 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -23,6 +23,11 @@ use tokio::sync::mpsc; /// Review thread system prompt. Edit `core/src/review_prompt.md` to customize. pub const REVIEW_PROMPT: &str = include_str!("../review_prompt.md"); +// Centralized templates for review-related user messages +pub const REVIEW_EXIT_SUCCESS_TMPL: &str = include_str!("../templates/review/exit_success.xml"); +pub const REVIEW_EXIT_INTERRUPTED_TMPL: &str = + include_str!("../templates/review/exit_interrupted.xml"); + /// API request payload for a single model turn #[derive(Default, Debug, Clone)] pub struct Prompt { @@ -192,6 +197,7 @@ fn strip_total_output_header(output: &str) -> Option<&str> { pub enum ResponseEvent { Created, OutputItemDone(ResponseItem), + OutputItemAdded(ResponseItem), Completed { response_id: String, token_usage: Option, @@ -200,9 +206,6 @@ pub enum ResponseEvent { ReasoningSummaryDelta(String), ReasoningContentDelta(String), ReasoningSummaryPartAdded, - WebSearchCallBegin { - call_id: String, - }, RateLimits(RateLimitSnapshot), } @@ -281,7 +284,7 @@ pub(crate) struct ResponsesApiRequest<'a> { } pub(crate) mod tools { - use crate::openai_tools::JsonSchema; + use crate::tools::spec::JsonSchema; use serde::Deserialize; use serde::Serialize; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index ce86b808c0..fc6b44c621 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1,4 +1,4 @@ -use std::borrow::Cow; +use std::collections::HashMap; use std::fmt::Debug; use std::path::PathBuf; use std::sync::Arc; @@ -6,18 +6,25 @@ use std::sync::atomic::AtomicU64; use crate::AuthManager; use crate::client_common::REVIEW_PROMPT; -use crate::event_mapping::map_response_item_to_event_messages; +use crate::features::Feature; use crate::function_tool::FunctionCallError; -use crate::review_format::format_review_findings_block; +use crate::mcp::auth::McpAuthStatusEntry; +use crate::mcp_connection_manager::DEFAULT_STARTUP_TIMEOUT; +use crate::parse_command::parse_command; +use crate::parse_turn_item; +use crate::response_processing::process_items; use crate::terminal; use crate::user_notification::UserNotifier; +use crate::util::error_or_panic; use async_channel::Receiver; use async_channel::Sender; -use codex_apply_patch::ApplyPatchAction; use codex_protocol::ConversationId; -use codex_protocol::protocol::ConversationPathResponseEvent; -use codex_protocol::protocol::ExitedReviewModeEvent; -use codex_protocol::protocol::McpAuthStatus; +use codex_protocol::items::TurnItem; +use codex_protocol::protocol::FileChange; +use codex_protocol::protocol::HasLegacyEvent; +use codex_protocol::protocol::ItemCompletedEvent; +use codex_protocol::protocol::ItemStartedEvent; +use codex_protocol::protocol::RawResponseItemEvent; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; @@ -28,65 +35,58 @@ use futures::future::BoxFuture; use futures::prelude::*; use futures::stream::FuturesOrdered; use mcp_types::CallToolResult; +use mcp_types::ListResourceTemplatesRequestParams; +use mcp_types::ListResourceTemplatesResult; +use mcp_types::ListResourcesRequestParams; +use mcp_types::ListResourcesResult; +use mcp_types::ReadResourceRequestParams; +use mcp_types::ReadResourceResult; use serde_json; use serde_json::Value; use tokio::sync::Mutex; use tokio::sync::oneshot; +use tokio_util::sync::CancellationToken; use tracing::debug; use tracing::error; use tracing::info; -use tracing::trace; use tracing::warn; use crate::ModelProviderInfo; -use crate::apply_patch::convert_apply_patch_to_protocol; use crate::client::ModelClient; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::config::Config; -use crate::config_types::ShellEnvironmentPolicy; -use crate::conversation_history::ConversationHistory; +use crate::config::types::McpServerTransportConfig; +use crate::config::types::ShellEnvironmentPolicy; +use crate::context_manager::ContextManager; use crate::environment_context::EnvironmentContext; use crate::error::CodexErr; use crate::error::Result as CodexResult; -use crate::exec::ExecToolCallOutput; #[cfg(test)] use crate::exec::StreamOutput; -use crate::exec_command::ExecCommandParams; -use crate::exec_command::ExecSessionManager; -use crate::exec_command::WriteStdinParams; -use crate::executor::Executor; -use crate::executor::ExecutorConfig; -use crate::executor::normalize_exec_result; +// Removed: legacy executor wiring replaced by ToolOrchestrator flows. +// legacy normalize_exec_result no longer used after orchestrator migration use crate::mcp::auth::compute_auth_statuses; use crate::mcp_connection_manager::McpConnectionManager; use crate::model_family::find_family_for_model; use crate::openai_model_info::get_model_info; -use crate::openai_tools::ToolsConfig; -use crate::openai_tools::ToolsConfigParams; -use crate::parse_command::parse_command; use crate::project_doc::get_user_instructions; -use crate::protocol::AgentMessageDeltaEvent; -use crate::protocol::AgentReasoningDeltaEvent; -use crate::protocol::AgentReasoningRawContentDeltaEvent; +use crate::protocol::AgentMessageContentDeltaEvent; use crate::protocol::AgentReasoningSectionBreakEvent; use crate::protocol::ApplyPatchApprovalRequestEvent; use crate::protocol::AskForApproval; use crate::protocol::BackgroundEventEvent; +use crate::protocol::DeprecationNoticeEvent; use crate::protocol::ErrorEvent; use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::ExecApprovalRequestEvent; -use crate::protocol::ExecCommandBeginEvent; -use crate::protocol::ExecCommandEndEvent; -use crate::protocol::InputItem; -use crate::protocol::ListCustomPromptsResponseEvent; use crate::protocol::Op; -use crate::protocol::PatchApplyBeginEvent; -use crate::protocol::PatchApplyEndEvent; use crate::protocol::RateLimitSnapshot; +use crate::protocol::ReasoningContentDeltaEvent; +use crate::protocol::ReasoningRawContentDeltaEvent; use crate::protocol::ReviewDecision; -use crate::protocol::ReviewOutputEvent; +use crate::protocol::SandboxCommandAssessment; use crate::protocol::SandboxPolicy; use crate::protocol::SessionConfiguredEvent; use crate::protocol::StreamErrorEvent; @@ -94,34 +94,40 @@ use crate::protocol::Submission; use crate::protocol::TokenCountEvent; use crate::protocol::TokenUsage; use crate::protocol::TurnDiffEvent; -use crate::protocol::WebSearchBeginEvent; use crate::rollout::RolloutRecorder; use crate::rollout::RolloutRecorderParams; use crate::shell; use crate::state::ActiveTurn; use crate::state::SessionServices; -use crate::state::TaskKind; -use crate::tasks::CompactTask; -use crate::tasks::RegularTask; +use crate::state::SessionState; +use crate::tasks::GhostSnapshotTask; use crate::tasks::ReviewTask; +use crate::tasks::SessionTask; +use crate::tasks::SessionTaskContext; use crate::tools::ToolRouter; use crate::tools::context::SharedTurnDiffTracker; -use crate::tools::format_exec_output_str; use crate::tools::parallel::ToolCallRuntime; +use crate::tools::sandboxing::ApprovalStore; +use crate::tools::spec::ToolsConfig; +use crate::tools::spec::ToolsConfigParams; use crate::turn_diff_tracker::TurnDiffTracker; use crate::unified_exec::UnifiedExecSessionManager; +use crate::user_instructions::DeveloperInstructions; use crate::user_instructions::UserInstructions; use crate::user_notification::UserNotification; use crate::util::backoff; +use codex_async_utils::OrCancelExt; use codex_otel::otel_event_manager::OtelEventManager; use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; -use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::models::ContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::InitialHistory; +use codex_protocol::user_input::UserInput; +use codex_utils_readiness::Readiness; +use codex_utils_readiness::ReadinessFlag; pub mod compact; use self::compact::build_compacted_history; @@ -130,9 +136,9 @@ use self::compact::collect_user_messages; /// The high-level interface to the Codex system. /// It operates as a queue pair where you send submissions and receive events. pub struct Codex { - next_id: AtomicU64, - tx_sub: Sender, - rx_event: Receiver, + pub(crate) next_id: AtomicU64, + pub(crate) tx_sub: Sender, + pub(crate) rx_event: Receiver, } /// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`], @@ -161,27 +167,32 @@ impl Codex { let config = Arc::new(config); - let configure_session = ConfigureSession { + let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), model: config.model.clone(), model_reasoning_effort: config.model_reasoning_effort, model_reasoning_summary: config.model_reasoning_summary, + developer_instructions: config.developer_instructions.clone(), user_instructions, base_instructions: config.base_instructions.clone(), + compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy, sandbox_policy: config.sandbox_policy.clone(), - notify: UserNotifier::new(config.notify.clone()), cwd: config.cwd.clone(), + original_config_do_not_use: Arc::clone(&config), + features: config.features.clone(), + session_source, }; // Generate a unique ID for the lifetime of this Codex session. - let (session, turn_context) = Session::new( - configure_session, + let session_source_clone = session_configuration.session_source.clone(); + let session = Session::new( + session_configuration, config.clone(), auth_manager.clone(), tx_event.clone(), conversation_history, - session_source, + session_source_clone, ) .await .map_err(|e| { @@ -191,7 +202,7 @@ impl Codex { let conversation_id = session.conversation_id; // This task will run until Op::Shutdown is received. - tokio::spawn(submission_loop(session, turn_context, config, rx_sub)); + tokio::spawn(submission_loop(session, config, rx_sub)); let codex = Codex { next_id: AtomicU64::new(0), tx_sub, @@ -235,8 +246,6 @@ impl Codex { } } -use crate::state::SessionState; - /// Context for an initialized model agent /// /// A session has at most 1 running task at a time, and can be interrupted by user input. @@ -252,19 +261,23 @@ pub(crate) struct Session { /// The context needed for a single turn of the conversation. #[derive(Debug)] pub(crate) struct TurnContext { + pub(crate) sub_id: String, pub(crate) client: ModelClient, /// The session's current working directory. All relative paths provided by /// the model as well as sandbox policies are resolved against this path /// instead of `std::env::current_dir()`. pub(crate) cwd: PathBuf, + pub(crate) developer_instructions: Option, pub(crate) base_instructions: Option, + pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, pub(crate) approval_policy: AskForApproval, pub(crate) sandbox_policy: SandboxPolicy, pub(crate) shell_environment_policy: ShellEnvironmentPolicy, pub(crate) tools_config: ToolsConfig, - pub(crate) is_review_mode: bool, pub(crate) final_output_json_schema: Option, + pub(crate) codex_linux_sandbox_exe: Option, + pub(crate) tool_call_gate: Arc, } impl TurnContext { @@ -273,10 +286,17 @@ impl TurnContext { .map(PathBuf::from) .map_or_else(|| self.cwd.clone(), |p| self.cwd.join(p)) } + + pub(crate) fn compact_prompt(&self) -> &str { + self.compact_prompt + .as_deref() + .unwrap_or(compact::SUMMARIZATION_PROMPT) + } } -/// Configure the model session. -struct ConfigureSession { +#[allow(dead_code)] +#[derive(Clone)] +pub(crate) struct SessionConfiguration { /// Provider identifier ("openai", "openrouter", ...). provider: ModelProviderInfo, @@ -286,19 +306,23 @@ struct ConfigureSession { model_reasoning_effort: Option, model_reasoning_summary: ReasoningSummaryConfig, + /// Developer instructions that supplement the base instructions. + developer_instructions: Option, + /// Model instructions that are appended to the base instructions. user_instructions: Option, /// Base instructions override. base_instructions: Option, + /// Compact prompt override. + compact_prompt: Option, + /// When to escalate for approval for execution approval_policy: AskForApproval, /// How to sandbox commands executed in the system sandbox_policy: SandboxPolicy, - notify: UserNotifier, - /// Working directory that should be treated as the *root* of the /// session. All relative paths supplied by the model as well as the /// execution sandbox are resolved against this directory **instead** @@ -307,32 +331,129 @@ struct ConfigureSession { /// `ConfigureSession` operation so that the business-logic layer can /// operate deterministically. cwd: PathBuf, + + /// Set of feature flags for this session + features: Features, + + // TODO(pakrym): Remove config from here + original_config_do_not_use: Arc, + /// Source of the session (cli, vscode, exec, mcp, ...) + session_source: SessionSource, +} + +impl SessionConfiguration { + pub(crate) fn apply(&self, updates: &SessionSettingsUpdate) -> Self { + let mut next_configuration = self.clone(); + if let Some(model) = updates.model.clone() { + next_configuration.model = model; + } + if let Some(effort) = updates.reasoning_effort { + next_configuration.model_reasoning_effort = effort; + } + if let Some(summary) = updates.reasoning_summary { + next_configuration.model_reasoning_summary = summary; + } + if let Some(approval_policy) = updates.approval_policy { + next_configuration.approval_policy = approval_policy; + } + if let Some(sandbox_policy) = updates.sandbox_policy.clone() { + next_configuration.sandbox_policy = sandbox_policy; + } + if let Some(cwd) = updates.cwd.clone() { + next_configuration.cwd = cwd; + } + next_configuration + } +} + +#[derive(Default, Clone)] +pub(crate) struct SessionSettingsUpdate { + pub(crate) cwd: Option, + pub(crate) approval_policy: Option, + pub(crate) sandbox_policy: Option, + pub(crate) model: Option, + pub(crate) reasoning_effort: Option>, + pub(crate) reasoning_summary: Option, + pub(crate) final_output_json_schema: Option>, } impl Session { + fn make_turn_context( + auth_manager: Option>, + otel_event_manager: &OtelEventManager, + provider: ModelProviderInfo, + session_configuration: &SessionConfiguration, + conversation_id: ConversationId, + sub_id: String, + ) -> TurnContext { + let config = session_configuration.original_config_do_not_use.clone(); + let model_family = find_family_for_model(&session_configuration.model) + .unwrap_or_else(|| config.model_family.clone()); + let mut per_turn_config = (*config).clone(); + per_turn_config.model = session_configuration.model.clone(); + per_turn_config.model_family = model_family.clone(); + per_turn_config.model_reasoning_effort = session_configuration.model_reasoning_effort; + per_turn_config.model_reasoning_summary = session_configuration.model_reasoning_summary; + if let Some(model_info) = get_model_info(&model_family) { + per_turn_config.model_context_window = Some(model_info.context_window); + } + + let otel_event_manager = otel_event_manager.clone().with_model( + session_configuration.model.as_str(), + session_configuration.model.as_str(), + ); + + let client = ModelClient::new( + Arc::new(per_turn_config), + auth_manager, + otel_event_manager, + provider, + session_configuration.model_reasoning_effort, + session_configuration.model_reasoning_summary, + conversation_id, + session_configuration.session_source.clone(), + ); + + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_family: &model_family, + features: &config.features, + }); + + TurnContext { + sub_id, + client, + cwd: session_configuration.cwd.clone(), + developer_instructions: session_configuration.developer_instructions.clone(), + base_instructions: session_configuration.base_instructions.clone(), + compact_prompt: session_configuration.compact_prompt.clone(), + user_instructions: session_configuration.user_instructions.clone(), + approval_policy: session_configuration.approval_policy, + sandbox_policy: session_configuration.sandbox_policy.clone(), + shell_environment_policy: config.shell_environment_policy.clone(), + tools_config, + final_output_json_schema: None, + codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), + tool_call_gate: Arc::new(ReadinessFlag::new()), + } + } + async fn new( - configure_session: ConfigureSession, + session_configuration: SessionConfiguration, config: Arc, auth_manager: Arc, tx_event: Sender, initial_history: InitialHistory, session_source: SessionSource, - ) -> anyhow::Result<(Arc, TurnContext)> { - let ConfigureSession { - provider, - model, - model_reasoning_effort, - model_reasoning_summary, - user_instructions, - base_instructions, - approval_policy, - sandbox_policy, - notify, - cwd, - } = configure_session; - debug!("Configuring session: model={model}; provider={provider:?}"); - if !cwd.is_absolute() { - return Err(anyhow::anyhow!("cwd is not absolute: {cwd:?}")); + ) -> anyhow::Result> { + debug!( + "Configuring session: model={}; provider={:?}", + session_configuration.model, session_configuration.provider + ); + if !session_configuration.cwd.is_absolute() { + return Err(anyhow::anyhow!( + "cwd is not absolute: {:?}", + session_configuration.cwd + )); } let (conversation_id, rollout_params) = match &initial_history { @@ -342,7 +463,7 @@ impl Session { conversation_id, RolloutRecorderParams::new( conversation_id, - user_instructions.clone(), + session_configuration.user_instructions.clone(), session_source, ), ) @@ -354,7 +475,7 @@ impl Session { }; // Error messages to dispatch after SessionConfigured is sent. - let mut post_session_configured_error_events = Vec::::new(); + let mut post_session_configured_events = Vec::::new(); // Kick off independent async setup tasks in parallel to reduce startup latency. // @@ -366,9 +487,6 @@ impl Session { let mcp_fut = McpConnectionManager::new( config.mcp_servers.clone(), - config - .features - .enabled(crate::features::Feature::RmcpClient), config.mcp_oauth_credentials_store_mode, ); let default_shell_fut = shell::default_user_shell(); @@ -398,8 +516,6 @@ impl Session { anyhow::anyhow!("failed to initialize rollout recorder: {e:#}") })?; let rollout_path = rollout_recorder.rollout_path.clone(); - // Create the mutable state for the Session. - let state = SessionState::new(); // Handle MCP manager result and record any startup failures. let (mcp_connection_manager, failed_clients) = match mcp_res { @@ -407,7 +523,7 @@ impl Session { Err(e) => { let message = format!("Failed to create MCP connection manager: {e:#}"); error!("{message}"); - post_session_configured_error_events.push(Event { + post_session_configured_events.push(Event { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::Error(ErrorEvent { message }), }); @@ -418,20 +534,10 @@ impl Session { // Surface individual client start-up failures to the user. if !failed_clients.is_empty() { for (server_name, err) in failed_clients { - let log_message = - format!("MCP client for `{server_name}` failed to start: {err:#}"); - error!("{log_message}"); - let display_message = if matches!( - auth_statuses.get(&server_name), - Some(McpAuthStatus::NotLoggedIn) - ) { - format!( - "The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}` to log in." - ) - } else { - log_message - }; - post_session_configured_error_events.push(Event { + let auth_entry = auth_statuses.get(&server_name); + let display_message = mcp_init_error_display(&server_name, auth_entry, &err); + warn!("MCP client for `{server_name}` failed to start: {err:#}"); + post_session_configured_events.push(Event { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::Error(ErrorEvent { message: display_message, @@ -440,6 +546,22 @@ impl Session { } } + for (alias, feature) in session_configuration.features.legacy_feature_usages() { + let canonical = feature.key(); + let summary = format!("`{alias}` is deprecated. Use `{canonical}` instead."); + let details = if alias == canonical { + None + } else { + Some(format!( + "Enable it with `--enable {canonical}` or `[features].{canonical}` in config.toml. See https://github.com/openai/codex/blob/main/docs/config.md#feature-flags for details." + )) + }; + post_session_configured_events.push(Event { + id: INITIAL_SUBMIT_ID.to_owned(), + msg: EventMsg::DeprecationNotice(DeprecationNoticeEvent { summary, details }), + }); + } + let otel_event_manager = OtelEventManager::new( conversation_id, config.model.as_str(), @@ -464,45 +586,19 @@ impl Session { config.active_profile.clone(), ); - // Now that the conversation id is final (may have been updated by resume), - // construct the model client. - let client = ModelClient::new( - config.clone(), - Some(auth_manager.clone()), - otel_event_manager, - provider.clone(), - model_reasoning_effort, - model_reasoning_summary, - conversation_id, - ); - let turn_context = TurnContext { - client, - tools_config: ToolsConfig::new(&ToolsConfigParams { - model_family: &config.model_family, - features: &config.features, - }), - user_instructions, - base_instructions, - approval_policy, - sandbox_policy, - shell_environment_policy: config.shell_environment_policy.clone(), - cwd, - is_review_mode: false, - final_output_json_schema: None, - }; + // Create the mutable state for the Session. + let state = SessionState::new(session_configuration.clone()); + let services = SessionServices { mcp_connection_manager, - session_manager: ExecSessionManager::default(), unified_exec_manager: UnifiedExecSessionManager::default(), - notifier: notify, + notifier: UserNotifier::new(config.notify.clone()), rollout: Mutex::new(Some(rollout_recorder)), user_shell: default_shell, show_raw_agent_reasoning: config.show_raw_agent_reasoning, - executor: Executor::new(ExecutorConfig::new( - turn_context.sandbox_policy.clone(), - turn_context.cwd.clone(), - config.codex_linux_sandbox_exe.clone(), - )), + auth_manager: Arc::clone(&auth_manager), + otel_event_manager, + tool_approvals: Mutex::new(ApprovalStore::default()), }; let sess = Arc::new(Session { @@ -517,33 +613,47 @@ impl Session { // Dispatch the SessionConfiguredEvent first and then report any errors. // If resuming, include converted initial messages in the payload so UIs can render them immediately. let initial_messages = initial_history.get_event_msgs(); - sess.record_initial_history(&turn_context, initial_history) - .await; let events = std::iter::once(Event { id: INITIAL_SUBMIT_ID.to_owned(), msg: EventMsg::SessionConfigured(SessionConfiguredEvent { session_id: conversation_id, - model, - reasoning_effort: model_reasoning_effort, + model: session_configuration.model.clone(), + reasoning_effort: session_configuration.model_reasoning_effort, history_log_id, history_entry_count, initial_messages, rollout_path, }), }) - .chain(post_session_configured_error_events.into_iter()); + .chain(post_session_configured_events.into_iter()); for event in events { - sess.send_event(event).await; + sess.send_event_raw(event).await; } - Ok((sess, turn_context)) + // record_initial_history can emit events. We record only after the SessionConfiguredEvent is emitted. + sess.record_initial_history(initial_history).await; + + Ok(sess) } pub(crate) fn get_tx_event(&self) -> Sender { self.tx_event.clone() } + /// Ensure all rollout writes are durably flushed. + pub(crate) async fn flush_rollout(&self) { + let recorder = { + let guard = self.services.rollout.lock().await; + guard.clone() + }; + if let Some(rec) = recorder + && let Err(e) = rec.flush().await + { + warn!("failed to flush rollout recorder: {e}"); + } + } + fn next_internal_sub_id(&self) -> String { let id = self .next_internal_sub_id @@ -551,16 +661,15 @@ impl Session { format!("auto-compact-{id}") } - async fn record_initial_history( - &self, - turn_context: &TurnContext, - conversation_history: InitialHistory, - ) { + async fn record_initial_history(&self, conversation_history: InitialHistory) { + let turn_context = self.new_turn(SessionSettingsUpdate::default()).await; match conversation_history { InitialHistory::New => { // Build and record initial items (user instructions + environment context) - let items = self.build_initial_context(turn_context); - self.record_conversation_items(&items).await; + let items = self.build_initial_context(&turn_context); + self.record_conversation_items(&turn_context, &items).await; + // Ensure initial items are visible to immediate readers (e.g., tests, forks). + self.flush_rollout().await; } InitialHistory::Resumed(_) | InitialHistory::Forked(_) => { let rollout_items = conversation_history.get_rollout_items(); @@ -568,7 +677,7 @@ impl Session { // Always add response items to conversation history let reconstructed_history = - self.reconstruct_history_from_rollout(turn_context, &rollout_items); + self.reconstruct_history_from_rollout(&turn_context, &rollout_items); if !reconstructed_history.is_empty() { self.record_into_history(&reconstructed_history).await; } @@ -577,12 +686,87 @@ impl Session { if persist && !rollout_items.is_empty() { self.persist_rollout_items(&rollout_items).await; } + // Flush after seeding history and any persisted rollout copy. + self.flush_rollout().await; } } } + pub(crate) async fn update_settings(&self, updates: SessionSettingsUpdate) { + let mut state = self.state.lock().await; + + state.session_configuration = state.session_configuration.apply(&updates); + } + + pub(crate) async fn new_turn(&self, updates: SessionSettingsUpdate) -> Arc { + let sub_id = self.next_internal_sub_id(); + self.new_turn_with_sub_id(sub_id, updates).await + } + + pub(crate) async fn new_turn_with_sub_id( + &self, + sub_id: String, + updates: SessionSettingsUpdate, + ) -> Arc { + let session_configuration = { + let mut state = self.state.lock().await; + let session_configuration = state.session_configuration.clone().apply(&updates); + state.session_configuration = session_configuration.clone(); + session_configuration + }; + + let mut turn_context: TurnContext = Self::make_turn_context( + Some(Arc::clone(&self.services.auth_manager)), + &self.services.otel_event_manager, + session_configuration.provider.clone(), + &session_configuration, + self.conversation_id, + sub_id, + ); + if let Some(final_schema) = updates.final_output_json_schema { + turn_context.final_output_json_schema = final_schema; + } + Arc::new(turn_context) + } + + fn build_environment_update_item( + &self, + previous: Option<&Arc>, + next: &TurnContext, + ) -> Option { + let prev = previous?; + + let prev_context = EnvironmentContext::from(prev.as_ref()); + let next_context = EnvironmentContext::from(next); + if prev_context.equals_except_shell(&next_context) { + return None; + } + Some(ResponseItem::from(EnvironmentContext::diff( + prev.as_ref(), + next, + ))) + } + /// Persist the event to rollout and send it to clients. - pub(crate) async fn send_event(&self, event: Event) { + pub(crate) async fn send_event(&self, turn_context: &TurnContext, msg: EventMsg) { + let legacy_source = msg.clone(); + let event = Event { + id: turn_context.sub_id.clone(), + msg, + }; + self.send_event_raw(event).await; + + let show_raw_agent_reasoning = self.show_raw_agent_reasoning(); + for legacy in legacy_source.as_legacy_events(show_raw_agent_reasoning) { + let legacy_event = Event { + id: turn_context.sub_id.clone(), + msg: legacy, + }; + self.send_event_raw(legacy_event).await; + } + } + + pub(crate) async fn send_event_raw(&self, event: Event) { // Persist the event into rollout (recorder filters as needed) let rollout_items = vec![RolloutItem::EventMsg(event.msg.clone())]; self.persist_rollout_items(&rollout_items).await; @@ -591,6 +775,57 @@ impl Session { } } + async fn emit_turn_item_started(&self, turn_context: &TurnContext, item: &TurnItem) { + self.send_event( + turn_context, + EventMsg::ItemStarted(ItemStartedEvent { + thread_id: self.conversation_id, + turn_id: turn_context.sub_id.clone(), + item: item.clone(), + }), + ) + .await; + } + + async fn emit_turn_item_completed(&self, turn_context: &TurnContext, item: TurnItem) { + self.send_event( + turn_context, + EventMsg::ItemCompleted(ItemCompletedEvent { + thread_id: self.conversation_id, + turn_id: turn_context.sub_id.clone(), + item, + }), + ) + .await; + } + + pub(crate) async fn assess_sandbox_command( + &self, + turn_context: &TurnContext, + call_id: &str, + command: &[String], + failure_message: Option<&str>, + ) -> Option { + let config = turn_context.client.config(); + let provider = turn_context.client.provider().clone(); + let auth_manager = Arc::clone(&self.services.auth_manager); + let otel = self.services.otel_event_manager.clone(); + crate::sandboxing::assessment::assess_command( + config, + provider, + auth_manager, + &otel, + self.conversation_id, + turn_context.client.get_session_source(), + call_id, + command, + &turn_context.sandbox_policy, + &turn_context.cwd, + failure_message, + ) + .await + } + /// Emit an exec approval request event and await the user's decision. /// /// The request is keyed by `sub_id`/`call_id` so matching responses are delivered @@ -598,12 +833,14 @@ impl Session { /// default `ReviewDecision` (`Denied`). pub async fn request_command_approval( &self, - sub_id: String, + turn_context: &TurnContext, call_id: String, command: Vec, cwd: PathBuf, reason: Option, + risk: Option, ) -> ReviewDecision { + let sub_id = turn_context.sub_id.clone(); // Add the tx_approve callback to the map before sending the request. let (tx_approve, rx_approve) = oneshot::channel(); let event_id = sub_id.clone(); @@ -622,28 +859,27 @@ impl Session { } let parsed_cmd = parse_command(&command); - let event = Event { - id: event_id, - msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { - call_id, - command, - cwd, - reason, - parsed_cmd, - }), - }; - self.send_event(event).await; + let event = EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { + call_id, + command, + cwd, + reason, + risk, + parsed_cmd, + }); + self.send_event(turn_context, event).await; rx_approve.await.unwrap_or_default() } pub async fn request_patch_approval( &self, - sub_id: String, + turn_context: &TurnContext, call_id: String, - action: &ApplyPatchAction, + changes: HashMap, reason: Option, grant_root: Option, ) -> oneshot::Receiver { + let sub_id = turn_context.sub_id.clone(); // Add the tx_approve callback to the map before sending the request. let (tx_approve, rx_approve) = oneshot::channel(); let event_id = sub_id.clone(); @@ -661,16 +897,13 @@ impl Session { warn!("Overwriting existing pending approval for sub_id: {event_id}"); } - let event = Event { - id: event_id, - msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { - call_id, - changes: convert_apply_patch_to_protocol(action), - reason, - grant_root, - }), - }; - self.send_event(event).await; + let event = EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id, + changes, + reason, + grant_root, + }); + self.send_event(turn_context, event).await; rx_approve } @@ -697,9 +930,14 @@ impl Session { /// Records input items: always append to conversation history and /// persist these response items to rollout. - async fn record_conversation_items(&self, items: &[ResponseItem]) { + pub(crate) async fn record_conversation_items( + &self, + turn_context: &TurnContext, + items: &[ResponseItem], + ) { self.record_into_history(items).await; self.persist_rollout_response_items(items).await; + self.send_raw_response_items(turn_context, items).await; } fn reconstruct_history_from_rollout( @@ -707,14 +945,14 @@ impl Session { turn_context: &TurnContext, rollout_items: &[RolloutItem], ) -> Vec { - let mut history = ConversationHistory::new(); + let mut history = ContextManager::new(); for item in rollout_items { match item { RolloutItem::ResponseItem(response_item) => { history.record_items(std::iter::once(response_item)); } RolloutItem::Compacted(compacted) => { - let snapshot = history.contents(); + let snapshot = history.get_history(); let user_messages = collect_user_messages(&snapshot); let rebuilt = build_compacted_history( self.build_initial_context(turn_context), @@ -726,7 +964,7 @@ impl Session { _ => {} } } - history.contents() + history.get_history() } /// Append ResponseItems to the in-memory conversation history only. @@ -735,7 +973,7 @@ impl Session { state.record_items(items.iter()); } - async fn replace_history(&self, items: Vec) { + pub(crate) async fn replace_history(&self, items: Vec) { let mut state = self.state.lock().await; state.replace_history(items); } @@ -749,10 +987,29 @@ impl Session { self.persist_rollout_items(&rollout_items).await; } + async fn send_raw_response_items(&self, turn_context: &TurnContext, items: &[ResponseItem]) { + for item in items { + self.send_event( + turn_context, + EventMsg::RawResponseItem(RawResponseItemEvent { item: item.clone() }), + ) + .await; + } + } + pub(crate) fn build_initial_context(&self, turn_context: &TurnContext) -> Vec { - let mut items = Vec::::with_capacity(2); + let mut items = Vec::::with_capacity(3); + if let Some(developer_instructions) = turn_context.developer_instructions.as_deref() { + items.push(DeveloperInstructions::new(developer_instructions.to_string()).into()); + } if let Some(user_instructions) = turn_context.user_instructions.as_deref() { - items.push(UserInstructions::new(user_instructions.to_string()).into()); + items.push( + UserInstructions { + text: user_instructions.to_string(), + directory: turn_context.cwd.to_string_lossy().into_owned(), + } + .into(), + ); } items.push(ResponseItem::from(EnvironmentContext::new( Some(turn_context.cwd.clone()), @@ -775,14 +1032,13 @@ impl Session { } } - pub(crate) async fn history_snapshot(&self) -> Vec { + pub(crate) async fn clone_history(&self) -> ContextManager { let state = self.state.lock().await; - state.history_snapshot() + state.clone_history() } async fn update_token_usage_info( &self, - sub_id: &str, turn_context: &TurnContext, token_usage: Option<&TokenUsage>, ) { @@ -795,246 +1051,117 @@ impl Session { ); } } - self.send_token_count_event(sub_id).await; + self.send_token_count_event(turn_context).await; } - async fn update_rate_limits(&self, sub_id: &str, new_rate_limits: RateLimitSnapshot) { + async fn update_rate_limits( + &self, + turn_context: &TurnContext, + new_rate_limits: RateLimitSnapshot, + ) { { let mut state = self.state.lock().await; state.set_rate_limits(new_rate_limits); } - self.send_token_count_event(sub_id).await; + self.send_token_count_event(turn_context).await; } - async fn send_token_count_event(&self, sub_id: &str) { + async fn send_token_count_event(&self, turn_context: &TurnContext) { let (info, rate_limits) = { let state = self.state.lock().await; state.token_info_and_rate_limits() }; - let event = Event { - id: sub_id.to_string(), - msg: EventMsg::TokenCount(TokenCountEvent { info, rate_limits }), - }; - self.send_event(event).await; + let event = EventMsg::TokenCount(TokenCountEvent { info, rate_limits }); + self.send_event(turn_context, event).await; } - async fn set_total_tokens_full(&self, sub_id: &str, turn_context: &TurnContext) { + async fn set_total_tokens_full(&self, turn_context: &TurnContext) { let context_window = turn_context.client.get_model_context_window(); if let Some(context_window) = context_window { { let mut state = self.state.lock().await; state.set_token_usage_full(context_window); } - self.send_token_count_event(sub_id).await; + self.send_token_count_event(turn_context).await; } } /// Record a user input item to conversation history and also persist a /// corresponding UserMessage EventMsg to rollout. - async fn record_input_and_rollout_usermsg(&self, response_input: &ResponseInputItem) { + async fn record_input_and_rollout_usermsg( + &self, + turn_context: &TurnContext, + response_input: &ResponseInputItem, + ) { let response_item: ResponseItem = response_input.clone().into(); // Add to conversation history and persist response item to rollout - self.record_conversation_items(std::slice::from_ref(&response_item)) + self.record_conversation_items(turn_context, std::slice::from_ref(&response_item)) .await; // Derive user message events and persist only UserMessage to rollout - let msgs = - map_response_item_to_event_messages(&response_item, self.show_raw_agent_reasoning()); - let user_msgs: Vec = msgs - .into_iter() - .filter_map(|m| match m { - EventMsg::UserMessage(ev) => Some(RolloutItem::EventMsg(EventMsg::UserMessage(ev))), - _ => None, - }) - .collect(); - if !user_msgs.is_empty() { - self.persist_rollout_items(&user_msgs).await; + let turn_item = parse_turn_item(&response_item); + + if let Some(item @ TurnItem::UserMessage(_)) = turn_item { + self.emit_turn_item_started(turn_context, &item).await; + self.emit_turn_item_completed(turn_context, item).await; } } - async fn on_exec_command_begin( + pub(crate) async fn notify_background_event( &self, - turn_diff_tracker: SharedTurnDiffTracker, - exec_command_context: ExecCommandContext, + turn_context: &TurnContext, + message: impl Into, ) { - let ExecCommandContext { - sub_id, - call_id, - command_for_display, - cwd, - apply_patch, - .. - } = exec_command_context; - let msg = match apply_patch { - Some(ApplyPatchCommandContext { - user_explicitly_approved_this_action, - changes, - }) => { - { - let mut tracker = turn_diff_tracker.lock().await; - tracker.on_patch_begin(&changes); - } - - EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id, - auto_approved: !user_explicitly_approved_this_action, - changes, - }) - } - None => EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id, - command: command_for_display.clone(), - cwd, - parsed_cmd: parse_command(&command_for_display), - }), - }; - let event = Event { - id: sub_id.to_string(), - msg, - }; - self.send_event(event).await; + let event = EventMsg::BackgroundEvent(BackgroundEventEvent { + message: message.into(), + }); + self.send_event(turn_context, event).await; } - async fn on_exec_command_end( - &self, - turn_diff_tracker: SharedTurnDiffTracker, - sub_id: &str, - call_id: &str, - output: &ExecToolCallOutput, - is_apply_patch: bool, + async fn notify_stream_error(&self, turn_context: &TurnContext, message: impl Into) { + let event = EventMsg::StreamError(StreamErrorEvent { + message: message.into(), + }); + self.send_event(turn_context, event).await; + } + + async fn maybe_start_ghost_snapshot( + self: &Arc, + turn_context: Arc, + cancellation_token: CancellationToken, ) { - let ExecToolCallOutput { - stdout, - stderr, - aggregated_output, - duration, - exit_code, - timed_out: _, - } = output; - // Send full stdout/stderr to clients; do not truncate. - let stdout = stdout.text.clone(); - let stderr = stderr.text.clone(); - let formatted_output = format_exec_output_str(output); - let aggregated_output: String = aggregated_output.text.clone(); - - let msg = if is_apply_patch { - EventMsg::PatchApplyEnd(PatchApplyEndEvent { - call_id: call_id.to_string(), - stdout, - stderr, - success: *exit_code == 0, - }) - } else { - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: call_id.to_string(), - stdout, - stderr, - aggregated_output, - exit_code: *exit_code, - duration: *duration, - formatted_output, - }) - }; - - let event = Event { - id: sub_id.to_string(), - msg, - }; - self.send_event(event).await; - - // If this is an apply_patch, after we emit the end patch, emit a second event - // with the full turn diff if there is one. - if is_apply_patch { - let unified_diff = { - let mut tracker = turn_diff_tracker.lock().await; - tracker.get_unified_diff() - }; - if let Ok(Some(unified_diff)) = unified_diff { - let msg = EventMsg::TurnDiff(TurnDiffEvent { unified_diff }); - let event = Event { - id: sub_id.into(), - msg, - }; - self.send_event(event).await; - } + if !self + .state + .lock() + .await + .session_configuration + .features + .enabled(Feature::GhostCommit) + { + return; } - } - /// Runs the exec tool call and emits events for the begin and end of the - /// command even on error. - /// - /// Returns the output of the exec tool call. - pub(crate) async fn run_exec_with_events( - &self, - turn_diff_tracker: SharedTurnDiffTracker, - prepared: PreparedExec, - approval_policy: AskForApproval, - ) -> Result { - let PreparedExec { context, request } = prepared; - let is_apply_patch = context.apply_patch.is_some(); - let sub_id = context.sub_id.clone(); - let call_id = context.call_id.clone(); + let token = match turn_context.tool_call_gate.subscribe().await { + Ok(token) => token, + Err(err) => { + warn!("failed to subscribe to ghost snapshot readiness: {err}"); + return; + } + }; - self.on_exec_command_begin(turn_diff_tracker.clone(), context.clone()) + info!("spawning ghost snapshot task"); + let task = GhostSnapshotTask::new(token); + Arc::new(task) + .run( + Arc::new(SessionTaskContext::new(self.clone())), + turn_context.clone(), + Vec::new(), + cancellation_token, + ) .await; - - let result = self - .services - .executor - .run(request, self, approval_policy, &context) - .await; - - let normalized = normalize_exec_result(&result); - let borrowed = normalized.event_output(); - - self.on_exec_command_end( - turn_diff_tracker, - &sub_id, - &call_id, - borrowed, - is_apply_patch, - ) - .await; - - drop(normalized); - - result - } - - /// Helper that emits a BackgroundEvent with the given message. This keeps - /// the call‑sites terse so adding more diagnostics does not clutter the - /// core agent logic. - pub(crate) async fn notify_background_event(&self, sub_id: &str, message: impl Into) { - let event = Event { - id: sub_id.to_string(), - msg: EventMsg::BackgroundEvent(BackgroundEventEvent { - message: message.into(), - }), - }; - self.send_event(event).await; - } - - async fn notify_stream_error(&self, sub_id: &str, message: impl Into) { - let event = Event { - id: sub_id.to_string(), - msg: EventMsg::StreamError(StreamErrorEvent { - message: message.into(), - }), - }; - self.send_event(event).await; - } - - /// Build the full turn input by concatenating the current conversation - /// history with additional items for this turn. - pub async fn turn_input_with_history(&self, extra: Vec) -> Vec { - let history = { - let state = self.state.lock().await; - state.history_snapshot() - }; - [history, extra].concat() } /// Returns the input if there was no task running to inject into - pub async fn inject_input(&self, input: Vec) -> Result<(), Vec> { + pub async fn inject_input(&self, input: Vec) -> Result<(), Vec> { let mut active = self.active_turn.lock().await; match active.as_mut() { Some(at) => { @@ -1057,6 +1184,39 @@ impl Session { } } + pub async fn list_resources( + &self, + server: &str, + params: Option, + ) -> anyhow::Result { + self.services + .mcp_connection_manager + .list_resources(server, params) + .await + } + + pub async fn list_resource_templates( + &self, + server: &str, + params: Option, + ) -> anyhow::Result { + self.services + .mcp_connection_manager + .list_resource_templates(server, params) + .await + } + + pub async fn read_resource( + &self, + server: &str, + params: ReadResourceRequestParams, + ) -> anyhow::Result { + self.services + .mcp_connection_manager + .read_resource(server, params) + .await + } + pub async fn call_tool( &self, server: &str, @@ -1075,61 +1235,11 @@ impl Session { .parse_tool_name(tool_name) } - pub(crate) async fn handle_exec_command_tool( - &self, - params: ExecCommandParams, - ) -> Result { - let result = self - .services - .session_manager - .handle_exec_command_request(params) - .await; - match result { - Ok(output) => Ok(output.to_text_output()), - Err(err) => Err(FunctionCallError::RespondToModel(err)), - } - } - - pub(crate) async fn handle_write_stdin_tool( - &self, - params: WriteStdinParams, - ) -> Result { - self.services - .session_manager - .handle_write_stdin_request(params) - .await - .map(|output| output.to_text_output()) - .map_err(FunctionCallError::RespondToModel) - } - - pub(crate) async fn run_unified_exec_request( - &self, - request: crate::unified_exec::UnifiedExecRequest<'_>, - ) -> Result { - self.services - .unified_exec_manager - .handle_request(request) - .await - } - pub async fn interrupt_task(self: &Arc) { info!("interrupt received: abort current task, if any"); self.abort_all_tasks(TurnAbortReason::Interrupted).await; } - fn interrupt_task_sync(&self) { - if let Ok(mut active) = self.active_turn.try_lock() - && let Some(at) = active.as_mut() - { - at.try_clear_pending_sync(); - let tasks = at.drain_tasks(); - *active = None; - for (_sub_id, task) in tasks { - task.handle.abort(); - } - } - } - pub(crate) fn notifier(&self) -> &UserNotifier { &self.services.notifier } @@ -1143,26 +1253,14 @@ impl Session { } } -impl Drop for Session { - fn drop(&mut self) { - self.interrupt_task_sync(); - } -} - -async fn submission_loop( - sess: Arc, - turn_context: TurnContext, - config: Arc, - rx_sub: Receiver, -) { - // Wrap once to avoid cloning TurnContext for each task. - let mut turn_context = Arc::new(turn_context); +async fn submission_loop(sess: Arc, config: Arc, rx_sub: Receiver) { + let mut previous_context: Option> = None; // To break out of this loop, send Op::Shutdown. while let Ok(sub) = rx_sub.recv().await { debug!(?sub, "Submission"); - match sub.op { + match sub.op.clone() { Op::Interrupt => { - sess.interrupt_task().await; + handlers::interrupt(&sess).await; } Op::OverrideTurnContext { cwd, @@ -1172,101 +1270,115 @@ async fn submission_loop( effort, summary, } => { - // Recalculate the persistent turn context with provided overrides. - let prev = Arc::clone(&turn_context); - let provider = prev.client.get_provider(); - - // Effective model + family - let (effective_model, effective_family) = if let Some(ref m) = model { - let fam = - find_family_for_model(m).unwrap_or_else(|| config.model_family.clone()); - (m.clone(), fam) - } else { - (prev.client.get_model(), prev.client.get_model_family()) - }; - - // Effective reasoning settings - let effective_effort = effort.unwrap_or(prev.client.get_reasoning_effort()); - let effective_summary = summary.unwrap_or(prev.client.get_reasoning_summary()); - - let auth_manager = prev.client.get_auth_manager(); - - // Build updated config for the client - let mut updated_config = (*config).clone(); - updated_config.model = effective_model.clone(); - updated_config.model_family = effective_family.clone(); - if let Some(model_info) = get_model_info(&effective_family) { - updated_config.model_context_window = Some(model_info.context_window); - } - - let otel_event_manager = prev.client.get_otel_event_manager().with_model( - updated_config.model.as_str(), - updated_config.model_family.slug.as_str(), - ); - - let client = ModelClient::new( - Arc::new(updated_config), - auth_manager, - otel_event_manager, - provider, - effective_effort, - effective_summary, - sess.conversation_id, - ); - - let new_approval_policy = approval_policy.unwrap_or(prev.approval_policy); - let new_sandbox_policy = sandbox_policy - .clone() - .unwrap_or(prev.sandbox_policy.clone()); - let new_cwd = cwd.clone().unwrap_or_else(|| prev.cwd.clone()); - - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_family: &effective_family, - features: &config.features, - }); - - let new_turn_context = TurnContext { - client, - tools_config, - user_instructions: prev.user_instructions.clone(), - base_instructions: prev.base_instructions.clone(), - approval_policy: new_approval_policy, - sandbox_policy: new_sandbox_policy.clone(), - shell_environment_policy: prev.shell_environment_policy.clone(), - cwd: new_cwd.clone(), - is_review_mode: false, - final_output_json_schema: None, - }; - - // Install the new persistent context for subsequent tasks/turns. - turn_context = Arc::new(new_turn_context); - - // Optionally persist changes to model / effort - if cwd.is_some() || approval_policy.is_some() || sandbox_policy.is_some() { - sess.record_conversation_items(&[ResponseItem::from(EnvironmentContext::new( + handlers::override_turn_context( + &sess, + SessionSettingsUpdate { cwd, approval_policy, sandbox_policy, - // Shell is not configurable from turn to turn - None, - ))]) + model, + reasoning_effort: effort, + reasoning_summary: summary, + ..Default::default() + }, + ) + .await; + } + Op::UserInput { .. } | Op::UserTurn { .. } => { + handlers::user_input_or_turn(&sess, sub.id.clone(), sub.op, &mut previous_context) .await; + } + Op::ExecApproval { id, decision } => { + handlers::exec_approval(&sess, id, decision).await; + } + Op::PatchApproval { id, decision } => { + handlers::patch_approval(&sess, id, decision).await; + } + Op::AddToHistory { text } => { + handlers::add_to_history(&sess, &config, text).await; + } + Op::GetHistoryEntryRequest { offset, log_id } => { + handlers::get_history_entry_request(&sess, &config, sub.id.clone(), offset, log_id) + .await; + } + Op::ListMcpTools => { + handlers::list_mcp_tools(&sess, &config, sub.id.clone()).await; + } + Op::ListCustomPrompts => { + handlers::list_custom_prompts(&sess, sub.id.clone()).await; + } + Op::Undo => { + handlers::undo(&sess, sub.id.clone()).await; + } + Op::Compact => { + handlers::compact(&sess, sub.id.clone()).await; + } + Op::RunUserShellCommand { command } => { + handlers::run_user_shell_command( + &sess, + sub.id.clone(), + command, + &mut previous_context, + ) + .await; + } + Op::Shutdown => { + if handlers::shutdown(&sess, sub.id.clone()).await { + break; } } - Op::UserInput { items } => { - turn_context - .client - .get_otel_event_manager() - .user_prompt(&items); - // attempt to inject input into current task - if let Err(items) = sess.inject_input(items).await { - // no current task, spawn a new one - sess.spawn_task(Arc::clone(&turn_context), sub.id, items, RegularTask) - .await; - } + Op::Review { review_request } => { + handlers::review(&sess, &config, sub.id.clone(), review_request).await; } + _ => {} // Ignore unknown ops; enum is non_exhaustive to allow extensions. + } + } + debug!("Agent loop exited"); +} + +/// Operation handlers +mod handlers { + use crate::codex::Session; + use crate::codex::SessionSettingsUpdate; + use crate::codex::TurnContext; + + use crate::codex::spawn_review_thread; + use crate::config::Config; + use crate::mcp::auth::compute_auth_statuses; + use crate::tasks::CompactTask; + use crate::tasks::RegularTask; + use crate::tasks::UndoTask; + use crate::tasks::UserShellCommandTask; + use codex_protocol::custom_prompts::CustomPrompt; + use codex_protocol::protocol::ErrorEvent; + use codex_protocol::protocol::Event; + use codex_protocol::protocol::EventMsg; + use codex_protocol::protocol::ListCustomPromptsResponseEvent; + use codex_protocol::protocol::Op; + use codex_protocol::protocol::ReviewDecision; + use codex_protocol::protocol::ReviewRequest; + use codex_protocol::protocol::TurnAbortReason; + use codex_protocol::user_input::UserInput; + use std::sync::Arc; + use tracing::info; + use tracing::warn; + + pub async fn interrupt(sess: &Arc) { + sess.interrupt_task().await; + } + + pub async fn override_turn_context(sess: &Session, updates: SessionSettingsUpdate) { + sess.update_settings(updates).await; + } + + pub async fn user_input_or_turn( + sess: &Arc, + sub_id: String, + op: Op, + previous_context: &mut Option>, + ) { + let (items, updates) = match op { Op::UserTurn { - items, cwd, approval_policy, sandbox_policy, @@ -1274,270 +1386,246 @@ async fn submission_loop( effort, summary, final_output_json_schema, - } => { - turn_context - .client - .get_otel_event_manager() - .user_prompt(&items); - // attempt to inject input into current task - if let Err(items) = sess.inject_input(items).await { - // Derive a fresh TurnContext for this turn using the provided overrides. - let provider = turn_context.client.get_provider(); - let auth_manager = turn_context.client.get_auth_manager(); + items, + } => ( + items, + SessionSettingsUpdate { + cwd: Some(cwd), + approval_policy: Some(approval_policy), + sandbox_policy: Some(sandbox_policy), + model: Some(model), + reasoning_effort: Some(effort), + reasoning_summary: Some(summary), + final_output_json_schema: Some(final_output_json_schema), + }, + ), + Op::UserInput { items } => (items, SessionSettingsUpdate::default()), + _ => unreachable!(), + }; - // Derive a model family for the requested model; fall back to the session's. - let model_family = find_family_for_model(&model) - .unwrap_or_else(|| config.model_family.clone()); + let current_context = sess.new_turn_with_sub_id(sub_id, updates).await; + current_context + .client + .get_otel_event_manager() + .user_prompt(&items); - // Create a per‑turn Config clone with the requested model/family. - let mut per_turn_config = (*config).clone(); - per_turn_config.model = model.clone(); - per_turn_config.model_family = model_family.clone(); - if let Some(model_info) = get_model_info(&model_family) { - per_turn_config.model_context_window = Some(model_info.context_window); - } - - let otel_event_manager = - turn_context.client.get_otel_event_manager().with_model( - per_turn_config.model.as_str(), - per_turn_config.model_family.slug.as_str(), - ); - - // Build a new client with per‑turn reasoning settings. - // Reuse the same provider and session id; auth defaults to env/API key. - let client = ModelClient::new( - Arc::new(per_turn_config), - auth_manager, - otel_event_manager, - provider, - effort, - summary, - sess.conversation_id, - ); - - let fresh_turn_context = TurnContext { - client, - tools_config: ToolsConfig::new(&ToolsConfigParams { - model_family: &model_family, - features: &config.features, - }), - user_instructions: turn_context.user_instructions.clone(), - base_instructions: turn_context.base_instructions.clone(), - approval_policy, - sandbox_policy, - shell_environment_policy: turn_context.shell_environment_policy.clone(), - cwd, - is_review_mode: false, - final_output_json_schema, - }; - - // if the environment context has changed, record it in the conversation history - let previous_env_context = EnvironmentContext::from(turn_context.as_ref()); - let new_env_context = EnvironmentContext::from(&fresh_turn_context); - if !new_env_context.equals_except_shell(&previous_env_context) { - let env_response_item = ResponseItem::from(new_env_context); - sess.record_conversation_items(std::slice::from_ref(&env_response_item)) - .await; - for msg in map_response_item_to_event_messages( - &env_response_item, - sess.show_raw_agent_reasoning(), - ) { - let event = Event { - id: sub.id.clone(), - msg, - }; - sess.send_event(event).await; - } - } - - // Install the new persistent context for subsequent tasks/turns. - turn_context = Arc::new(fresh_turn_context); - - // no current task, spawn a new one with the per-turn context - sess.spawn_task(Arc::clone(&turn_context), sub.id, items, RegularTask) - .await; - } - } - Op::ExecApproval { id, decision } => match decision { - ReviewDecision::Abort => { - sess.interrupt_task().await; - } - other => sess.notify_approval(&id, other).await, - }, - Op::PatchApproval { id, decision } => match decision { - ReviewDecision::Abort => { - sess.interrupt_task().await; - } - other => sess.notify_approval(&id, other).await, - }, - Op::AddToHistory { text } => { - let id = sess.conversation_id; - let config = config.clone(); - tokio::spawn(async move { - if let Err(e) = crate::message_history::append_entry(&text, &id, &config).await - { - warn!("failed to append to message history: {e}"); - } - }); + // Attempt to inject input into current task + if let Err(items) = sess.inject_input(items).await { + if let Some(env_item) = + sess.build_environment_update_item(previous_context.as_ref(), ¤t_context) + { + sess.record_conversation_items(¤t_context, std::slice::from_ref(&env_item)) + .await; } - Op::GetHistoryEntryRequest { offset, log_id } => { - let config = config.clone(); - let sess_clone = sess.clone(); - let sub_id = sub.id.clone(); - - tokio::spawn(async move { - // Run lookup in blocking thread because it does file IO + locking. - let entry_opt = tokio::task::spawn_blocking(move || { - crate::message_history::lookup(log_id, offset, &config) - }) - .await - .unwrap_or(None); - - let event = Event { - id: sub_id, - msg: EventMsg::GetHistoryEntryResponse( - crate::protocol::GetHistoryEntryResponseEvent { - offset, - log_id, - entry: entry_opt.map(|e| { - codex_protocol::message_history::HistoryEntry { - conversation_id: e.session_id, - ts: e.ts, - text: e.text, - } - }), - }, - ), - }; - - sess_clone.send_event(event).await; - }); - } - Op::ListMcpTools => { - let sub_id = sub.id.clone(); - - // This is a cheap lookup from the connection manager's cache. - let tools = sess.services.mcp_connection_manager.list_all_tools(); - let auth_statuses = compute_auth_statuses( - config.mcp_servers.iter(), - config.mcp_oauth_credentials_store_mode, - ) + sess.spawn_task(Arc::clone(¤t_context), items, RegularTask) .await; - let event = Event { - id: sub_id, - msg: EventMsg::McpListToolsResponse( - crate::protocol::McpListToolsResponseEvent { - tools, - auth_statuses, - }, - ), - }; - sess.send_event(event).await; - } - Op::ListCustomPrompts => { - let sub_id = sub.id.clone(); - - let custom_prompts: Vec = - if let Some(dir) = crate::custom_prompts::default_prompts_dir() { - crate::custom_prompts::discover_prompts_in(&dir).await - } else { - Vec::new() - }; - - let event = Event { - id: sub_id, - msg: EventMsg::ListCustomPromptsResponse(ListCustomPromptsResponseEvent { - custom_prompts, - }), - }; - sess.send_event(event).await; - } - Op::Compact => { - // Attempt to inject input into current task - if let Err(items) = sess - .inject_input(vec![InputItem::Text { - text: compact::SUMMARIZATION_PROMPT.to_string(), - }]) - .await - { - sess.spawn_task(Arc::clone(&turn_context), sub.id, items, CompactTask) - .await; - } - } - Op::Shutdown => { - sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - info!("Shutting down Codex instance"); - - // Gracefully flush and shutdown rollout recorder on session end so tests - // that inspect the rollout file do not race with the background writer. - let recorder_opt = { - let mut guard = sess.services.rollout.lock().await; - guard.take() - }; - if let Some(rec) = recorder_opt - && let Err(e) = rec.shutdown().await - { - warn!("failed to shutdown rollout recorder: {e}"); - let event = Event { - id: sub.id.clone(), - msg: EventMsg::Error(ErrorEvent { - message: "Failed to shutdown rollout recorder".to_string(), - }), - }; - sess.send_event(event).await; - } - - let event = Event { - id: sub.id.clone(), - msg: EventMsg::ShutdownComplete, - }; - sess.send_event(event).await; - break; - } - Op::GetPath => { - let sub_id = sub.id.clone(); - // Flush rollout writes before returning the path so readers observe a consistent file. - let (path, rec_opt) = { - let guard = sess.services.rollout.lock().await; - match guard.as_ref() { - Some(rec) => (rec.get_rollout_path(), Some(rec.clone())), - None => { - error!("rollout recorder not found"); - continue; - } - } - }; - if let Some(rec) = rec_opt - && let Err(e) = rec.flush().await - { - warn!("failed to flush rollout recorder before GetHistory: {e}"); - } - let event = Event { - id: sub_id.clone(), - msg: EventMsg::ConversationPath(ConversationPathResponseEvent { - conversation_id: sess.conversation_id, - path, - }), - }; - sess.send_event(event).await; - } - Op::Review { review_request } => { - spawn_review_thread( - sess.clone(), - config.clone(), - turn_context.clone(), - sub.id, - review_request, - ) - .await; - } - _ => { - // Ignore unknown ops; enum is non_exhaustive to allow extensions. - } + *previous_context = Some(current_context); } } - debug!("Agent loop exited"); + + pub async fn run_user_shell_command( + sess: &Arc, + sub_id: String, + command: String, + previous_context: &mut Option>, + ) { + let turn_context = sess + .new_turn_with_sub_id(sub_id, SessionSettingsUpdate::default()) + .await; + sess.spawn_task( + Arc::clone(&turn_context), + Vec::new(), + UserShellCommandTask::new(command), + ) + .await; + *previous_context = Some(turn_context); + } + + pub async fn exec_approval(sess: &Arc, id: String, decision: ReviewDecision) { + match decision { + ReviewDecision::Abort => { + sess.interrupt_task().await; + } + other => sess.notify_approval(&id, other).await, + } + } + + pub async fn patch_approval(sess: &Arc, id: String, decision: ReviewDecision) { + match decision { + ReviewDecision::Abort => { + sess.interrupt_task().await; + } + other => sess.notify_approval(&id, other).await, + } + } + + pub async fn add_to_history(sess: &Arc, config: &Arc, text: String) { + let id = sess.conversation_id; + let config = Arc::clone(config); + tokio::spawn(async move { + if let Err(e) = crate::message_history::append_entry(&text, &id, &config).await { + warn!("failed to append to message history: {e}"); + } + }); + } + + pub async fn get_history_entry_request( + sess: &Arc, + config: &Arc, + sub_id: String, + offset: usize, + log_id: u64, + ) { + let config = Arc::clone(config); + let sess_clone = Arc::clone(sess); + + tokio::spawn(async move { + // Run lookup in blocking thread because it does file IO + locking. + let entry_opt = tokio::task::spawn_blocking(move || { + crate::message_history::lookup(log_id, offset, &config) + }) + .await + .unwrap_or(None); + + let event = Event { + id: sub_id, + msg: EventMsg::GetHistoryEntryResponse( + crate::protocol::GetHistoryEntryResponseEvent { + offset, + log_id, + entry: entry_opt.map(|e| codex_protocol::message_history::HistoryEntry { + conversation_id: e.session_id, + ts: e.ts, + text: e.text, + }), + }, + ), + }; + + sess_clone.send_event_raw(event).await; + }); + } + + pub async fn list_mcp_tools(sess: &Session, config: &Arc, sub_id: String) { + // This is a cheap lookup from the connection manager's cache. + let tools = sess.services.mcp_connection_manager.list_all_tools(); + let (auth_status_entries, resources, resource_templates) = tokio::join!( + compute_auth_statuses( + config.mcp_servers.iter(), + config.mcp_oauth_credentials_store_mode, + ), + sess.services.mcp_connection_manager.list_all_resources(), + sess.services + .mcp_connection_manager + .list_all_resource_templates() + ); + let auth_statuses = auth_status_entries + .iter() + .map(|(name, entry)| (name.clone(), entry.auth_status)) + .collect(); + let event = Event { + id: sub_id, + msg: EventMsg::McpListToolsResponse(crate::protocol::McpListToolsResponseEvent { + tools, + resources, + resource_templates, + auth_statuses, + }), + }; + sess.send_event_raw(event).await; + } + + pub async fn list_custom_prompts(sess: &Session, sub_id: String) { + let custom_prompts: Vec = + if let Some(dir) = crate::custom_prompts::default_prompts_dir() { + crate::custom_prompts::discover_prompts_in(&dir).await + } else { + Vec::new() + }; + + let event = Event { + id: sub_id, + msg: EventMsg::ListCustomPromptsResponse(ListCustomPromptsResponseEvent { + custom_prompts, + }), + }; + sess.send_event_raw(event).await; + } + + pub async fn undo(sess: &Arc, sub_id: String) { + let turn_context = sess + .new_turn_with_sub_id(sub_id, SessionSettingsUpdate::default()) + .await; + sess.spawn_task(turn_context, Vec::new(), UndoTask::new()) + .await; + } + + pub async fn compact(sess: &Arc, sub_id: String) { + let turn_context = sess + .new_turn_with_sub_id(sub_id, SessionSettingsUpdate::default()) + .await; + // Attempt to inject input into current task + if let Err(items) = sess + .inject_input(vec![UserInput::Text { + text: turn_context.compact_prompt().to_string(), + }]) + .await + { + sess.spawn_task(Arc::clone(&turn_context), items, CompactTask) + .await; + } + } + + pub async fn shutdown(sess: &Arc, sub_id: String) -> bool { + sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + info!("Shutting down Codex instance"); + + // Gracefully flush and shutdown rollout recorder on session end so tests + // that inspect the rollout file do not race with the background writer. + let recorder_opt = { + let mut guard = sess.services.rollout.lock().await; + guard.take() + }; + if let Some(rec) = recorder_opt + && let Err(e) = rec.shutdown().await + { + warn!("failed to shutdown rollout recorder: {e}"); + let event = Event { + id: sub_id.clone(), + msg: EventMsg::Error(ErrorEvent { + message: "Failed to shutdown rollout recorder".to_string(), + }), + }; + sess.send_event_raw(event).await; + } + + let event = Event { + id: sub_id, + msg: EventMsg::ShutdownComplete, + }; + sess.send_event_raw(event).await; + true + } + + pub async fn review( + sess: &Arc, + config: &Arc, + sub_id: String, + review_request: ReviewRequest, + ) { + let turn_context = sess + .new_turn_with_sub_id(sub_id.clone(), SessionSettingsUpdate::default()) + .await; + spawn_review_thread( + Arc::clone(sess), + Arc::clone(config), + turn_context.clone(), + sub_id, + review_request, + ) + .await; + } } /// Spawn a review thread using the given prompt. @@ -1551,12 +1639,11 @@ async fn spawn_review_thread( let model = config.review_model.clone(); let review_model_family = find_family_for_model(&model) .unwrap_or_else(|| parent_turn_context.client.get_model_family()); - // For reviews, disable plan, web_search, view_image regardless of global settings. + // For reviews, disable web_search and view_image regardless of global settings. let mut review_features = config.features.clone(); - review_features.disable(crate::features::Feature::PlanTool); - review_features.disable(crate::features::Feature::WebSearchRequest); - review_features.disable(crate::features::Feature::ViewImageTool); - review_features.disable(crate::features::Feature::StreamableShell); + review_features + .disable(crate::features::Feature::WebSearchRequest) + .disable(crate::features::Feature::ViewImageTool); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_family: &review_model_family, features: &review_features, @@ -1595,37 +1682,36 @@ async fn spawn_review_thread( per_turn_config.model_reasoning_effort, per_turn_config.model_reasoning_summary, sess.conversation_id, + parent_turn_context.client.get_session_source(), ); let review_turn_context = TurnContext { + sub_id: sub_id.to_string(), client, tools_config, + developer_instructions: None, user_instructions: None, base_instructions: Some(base_instructions.clone()), + compact_prompt: parent_turn_context.compact_prompt.clone(), approval_policy: parent_turn_context.approval_policy, sandbox_policy: parent_turn_context.sandbox_policy.clone(), shell_environment_policy: parent_turn_context.shell_environment_policy.clone(), cwd: parent_turn_context.cwd.clone(), - is_review_mode: true, final_output_json_schema: None, + codex_linux_sandbox_exe: parent_turn_context.codex_linux_sandbox_exe.clone(), + tool_call_gate: Arc::new(ReadinessFlag::new()), }; // Seed the child task with the review prompt as the initial user message. - let input: Vec = vec![InputItem::Text { + let input: Vec = vec![UserInput::Text { text: review_prompt, }]; let tc = Arc::new(review_turn_context); - - // Clone sub_id for the upcoming announcement before moving it into the task. - let sub_id_for_event = sub_id.clone(); - sess.spawn_task(tc.clone(), sub_id, input, ReviewTask).await; + sess.spawn_task(tc.clone(), input, ReviewTask).await; // Announce entering review mode so UIs can switch modes. - sess.send_event(Event { - id: sub_id_for_event, - msg: EventMsg::EnteredReviewMode(review_request), - }) - .await; + sess.send_event(&tc, EventMsg::EnteredReviewMode(review_request)) + .await; } /// Takes a user message as input and runs a loop where, at each turn, the model @@ -1642,42 +1728,26 @@ async fn spawn_review_thread( /// - If the model sends only an assistant message, we record it in the /// conversation history and consider the task complete. /// -/// Review mode: when `turn_context.is_review_mode` is true, the turn runs in an -/// isolated in-memory thread without the parent session's prior history or -/// user_instructions. Emits ExitedReviewMode upon final review message. pub(crate) async fn run_task( sess: Arc, turn_context: Arc, - sub_id: String, - input: Vec, - task_kind: TaskKind, + input: Vec, + cancellation_token: CancellationToken, ) -> Option { if input.is_empty() { return None; } - let event = Event { - id: sub_id.clone(), - msg: EventMsg::TaskStarted(TaskStartedEvent { - model_context_window: turn_context.client.get_model_context_window(), - }), - }; - sess.send_event(event).await; + let event = EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: turn_context.client.get_model_context_window(), + }); + sess.send_event(&turn_context, event).await; let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input); - // For review threads, keep an isolated in-memory history so the - // model sees a fresh conversation without the parent session's history. - // For normal turns, continue recording to the session history as before. - let is_review_mode = turn_context.is_review_mode; - let mut review_thread_history: Vec = Vec::new(); - if is_review_mode { - // Seed review threads with environment context so the model knows the working directory. - review_thread_history.extend(sess.build_initial_context(turn_context.as_ref())); - review_thread_history.push(initial_input_for_turn.into()); - } else { - sess.record_input_and_rollout_usermsg(&initial_input_for_turn) - .await; - } + sess.record_input_and_rollout_usermsg(turn_context.as_ref(), &initial_input_for_turn) + .await; + sess.maybe_start_ghost_snapshot(Arc::clone(&turn_context), cancellation_token.child_token()) + .await; let mut last_agent_message: Option = None; // Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains // many turns, from the perspective of the user, it is a single turn. @@ -1696,45 +1766,26 @@ pub(crate) async fn run_task( .collect::>(); // Construct the input that we will send to the model. - // - // - For review threads, use the isolated in-memory history so the - // model sees a fresh conversation (no parent history/user_instructions). - // - // - For normal turns, use the session's full history. When using the - // chat completions API (or ZDR clients), the model needs the full - // conversation history on each turn. The rollout file, however, should - // only record the new items that originated in this turn so that it - // represents an append-only log without duplicates. - let turn_input: Vec = if is_review_mode { - if !pending_input.is_empty() { - review_thread_history.extend(pending_input); - } - review_thread_history.clone() - } else { - sess.record_conversation_items(&pending_input).await; - sess.turn_input_with_history(pending_input).await + let turn_input: Vec = { + sess.record_conversation_items(&turn_context, &pending_input) + .await; + sess.clone_history().await.get_history_for_prompt() }; - let turn_input_messages: Vec = turn_input + let turn_input_messages = turn_input .iter() - .filter_map(|item| match item { - ResponseItem::Message { content, .. } => Some(content), + .filter_map(|item| match parse_turn_item(item) { + Some(TurnItem::UserMessage(user_message)) => Some(user_message), _ => None, }) - .flat_map(|content| { - content.iter().filter_map(|item| match item { - ContentItem::OutputText { text } => Some(text.clone()), - _ => None, - }) - }) - .collect(); + .map(|user_message| user_message.message()) + .collect::>(); match run_turn( Arc::clone(&sess), Arc::clone(&turn_context), Arc::clone(&turn_diff_tracker), - sub_id.clone(), turn_input, - task_kind, + cancellation_token.child_token(), ) .await { @@ -1751,111 +1802,10 @@ pub(crate) async fn run_task( .as_ref() .map(TokenUsage::tokens_in_context_window); let token_limit_reached = total_usage_tokens - .map(|tokens| (tokens as i64) >= limit) + .map(|tokens| tokens >= limit) .unwrap_or(false); - let mut items_to_record_in_conversation_history = Vec::::new(); - let mut responses = Vec::::new(); - for processed_response_item in processed_items { - let ProcessedResponseItem { item, response } = processed_response_item; - match (&item, &response) { - (ResponseItem::Message { role, .. }, None) if role == "assistant" => { - // If the model returned a message, we need to record it. - items_to_record_in_conversation_history.push(item); - } - ( - ResponseItem::LocalShellCall { .. }, - Some(ResponseInputItem::FunctionCallOutput { call_id, output }), - ) => { - items_to_record_in_conversation_history.push(item); - items_to_record_in_conversation_history.push( - ResponseItem::FunctionCallOutput { - call_id: call_id.clone(), - output: output.clone(), - }, - ); - } - ( - ResponseItem::FunctionCall { .. }, - Some(ResponseInputItem::FunctionCallOutput { call_id, output }), - ) => { - items_to_record_in_conversation_history.push(item); - items_to_record_in_conversation_history.push( - ResponseItem::FunctionCallOutput { - call_id: call_id.clone(), - output: output.clone(), - }, - ); - } - ( - ResponseItem::CustomToolCall { .. }, - Some(ResponseInputItem::CustomToolCallOutput { call_id, output }), - ) => { - items_to_record_in_conversation_history.push(item); - items_to_record_in_conversation_history.push( - ResponseItem::CustomToolCallOutput { - call_id: call_id.clone(), - output: output.clone(), - }, - ); - } - ( - ResponseItem::FunctionCall { .. }, - Some(ResponseInputItem::McpToolCallOutput { call_id, result }), - ) => { - items_to_record_in_conversation_history.push(item); - let output = match result { - Ok(call_tool_result) => { - convert_call_tool_result_to_function_call_output_payload( - call_tool_result, - ) - } - Err(err) => FunctionCallOutputPayload { - content: err.clone(), - success: Some(false), - }, - }; - items_to_record_in_conversation_history.push( - ResponseItem::FunctionCallOutput { - call_id: call_id.clone(), - output, - }, - ); - } - ( - ResponseItem::Reasoning { - id, - summary, - content, - encrypted_content, - }, - None, - ) => { - items_to_record_in_conversation_history.push(ResponseItem::Reasoning { - id: id.clone(), - summary: summary.clone(), - content: content.clone(), - encrypted_content: encrypted_content.clone(), - }); - } - _ => { - warn!("Unexpected response item: {item:?} with response: {response:?}"); - } - }; - if let Some(response) = response { - responses.push(response); - } - } - - // Only attempt to take the lock if there is something to record. - if !items_to_record_in_conversation_history.is_empty() { - if is_review_mode { - review_thread_history - .extend(items_to_record_in_conversation_history.clone()); - } else { - sess.record_conversation_items(&items_to_record_in_conversation_history) - .await; - } - } + let (responses, items_to_record_in_conversation_history) = + process_items(processed_items, &sess, &turn_context).await; if token_limit_reached { if auto_compact_recently_attempted { @@ -1863,15 +1813,12 @@ pub(crate) async fn run_task( let current_tokens = total_usage_tokens .map(|tokens| tokens.to_string()) .unwrap_or_else(|| "unknown".to_string()); - let event = Event { - id: sub_id.clone(), - msg: EventMsg::Error(ErrorEvent { - message: format!( - "Conversation is still above the token limit after automatic summarization (limit {limit_str}, current {current_tokens}). Please start a new session or trim your input." - ), - }), - }; - sess.send_event(event).await; + let event = EventMsg::Error(ErrorEvent { + message: format!( + "Conversation is still above the token limit after automatic summarization (limit {limit_str}, current {current_tokens}). Please start a new session or trim your input." + ), + }); + sess.send_event(&turn_context, event).await; break; } auto_compact_recently_attempted = true; @@ -1888,7 +1835,8 @@ pub(crate) async fn run_task( sess.notifier() .notify(&UserNotification::AgentTurnComplete { thread_id: sess.conversation_id.to_string(), - turn_id: sub_id.clone(), + turn_id: turn_context.sub_id.clone(), + cwd: turn_context.cwd.display().to_string(), input_messages: turn_input_messages, last_assistant_message: last_agent_message.clone(), }); @@ -1896,72 +1844,34 @@ pub(crate) async fn run_task( } continue; } + Err(CodexErr::TurnAborted { + dangling_artifacts: processed_items, + }) => { + let _ = process_items(processed_items, &sess, &turn_context).await; + // Aborted turn is reported via a different event. + break; + } Err(e) => { info!("Turn error: {e:#}"); - let event = Event { - id: sub_id.clone(), - msg: EventMsg::Error(ErrorEvent { - message: e.to_string(), - }), - }; - sess.send_event(event).await; + let event = EventMsg::Error(ErrorEvent { + message: e.to_string(), + }); + sess.send_event(&turn_context, event).await; // let the user continue the conversation break; } } } - // If this was a review thread and we have a final assistant message, - // try to parse it as a ReviewOutput. - // - // If parsing fails, construct a minimal ReviewOutputEvent using the plain - // text as the overall explanation. Else, just exit review mode with None. - // - // Emits an ExitedReviewMode event with the parsed review output. - if turn_context.is_review_mode { - exit_review_mode( - sess.clone(), - sub_id.clone(), - last_agent_message.as_deref().map(parse_review_output_event), - ) - .await; - } - last_agent_message } -/// Parse the review output; when not valid JSON, build a structured -/// fallback that carries the plain text as the overall explanation. -/// -/// Returns: a ReviewOutputEvent parsed from JSON or a fallback populated from text. -fn parse_review_output_event(text: &str) -> ReviewOutputEvent { - // Try direct parse first - if let Ok(ev) = serde_json::from_str::(text) { - return ev; - } - // If wrapped in markdown fences or extra prose, attempt to extract the first JSON object - if let (Some(start), Some(end)) = (text.find('{'), text.rfind('}')) - && start < end - && let Some(slice) = text.get(start..=end) - && let Ok(ev) = serde_json::from_str::(slice) - { - return ev; - } - // Not JSON – return a structured ReviewOutputEvent that carries - // the plain text as the overall explanation. - ReviewOutputEvent { - overall_explanation: text.to_string(), - ..Default::default() - } -} - async fn run_turn( sess: Arc, turn_context: Arc, turn_diff_tracker: SharedTurnDiffTracker, - sub_id: String, input: Vec, - task_kind: TaskKind, + cancellation_token: CancellationToken, ) -> CodexResult { let mcp_tools = sess.services.mcp_connection_manager.list_all_tools(); let router = Arc::new(ToolRouter::from_config( @@ -1989,28 +1899,36 @@ async fn run_turn( Arc::clone(&sess), Arc::clone(&turn_context), Arc::clone(&turn_diff_tracker), - &sub_id, &prompt, - task_kind, + cancellation_token.child_token(), ) .await { Ok(output) => return Ok(output), + Err(CodexErr::TurnAborted { + dangling_artifacts: processed_items, + }) => { + return Err(CodexErr::TurnAborted { + dangling_artifacts: processed_items, + }); + } Err(CodexErr::Interrupted) => return Err(CodexErr::Interrupted), Err(CodexErr::EnvVar(var)) => return Err(CodexErr::EnvVar(var)), Err(e @ CodexErr::Fatal(_)) => return Err(e), Err(e @ CodexErr::ContextWindowExceeded) => { - sess.set_total_tokens_full(&sub_id, &turn_context).await; + sess.set_total_tokens_full(&turn_context).await; return Err(e); } Err(CodexErr::UsageLimitReached(e)) => { let rate_limits = e.rate_limits.clone(); if let Some(rate_limits) = rate_limits { - sess.update_rate_limits(&sub_id, rate_limits).await; + sess.update_rate_limits(&turn_context, rate_limits).await; } return Err(CodexErr::UsageLimitReached(e)); } Err(CodexErr::UsageNotIncluded) => return Err(CodexErr::UsageNotIncluded), + Err(e @ CodexErr::QuotaExceeded) => return Err(e), + Err(e @ CodexErr::RefreshTokenFailed(_)) => return Err(e), Err(e) => { // Use the configured provider-specific stream retry budget. let max_retries = turn_context.client.get_provider().stream_max_retries(); @@ -2028,8 +1946,8 @@ async fn run_turn( // user understands what is happening instead of staring // at a seemingly frozen screen. sess.notify_stream_error( - &sub_id, - format!("Re-connecting... {retries}/{max_retries}"), + &turn_context, + format!("Reconnecting... {retries}/{max_retries}"), ) .await; @@ -2047,9 +1965,9 @@ async fn run_turn( /// "handled" such that it produces a `ResponseInputItem` that needs to be /// sent back to the model on the next turn. #[derive(Debug)] -pub(crate) struct ProcessedResponseItem { - pub(crate) item: ResponseItem, - pub(crate) response: Option, +pub struct ProcessedResponseItem { + pub item: ResponseItem, + pub response: Option, } #[derive(Debug)] @@ -2058,70 +1976,15 @@ struct TurnRunResult { total_token_usage: Option, } +#[allow(clippy::too_many_arguments)] async fn try_run_turn( router: Arc, sess: Arc, turn_context: Arc, turn_diff_tracker: SharedTurnDiffTracker, - sub_id: &str, prompt: &Prompt, - task_kind: TaskKind, + cancellation_token: CancellationToken, ) -> CodexResult { - // call_ids that are part of this response. - let completed_call_ids = prompt - .input - .iter() - .filter_map(|ri| match ri { - ResponseItem::FunctionCallOutput { call_id, .. } => Some(call_id), - ResponseItem::LocalShellCall { - call_id: Some(call_id), - .. - } => Some(call_id), - ResponseItem::CustomToolCallOutput { call_id, .. } => Some(call_id), - _ => None, - }) - .collect::>(); - - // call_ids that were pending but are not part of this response. - // This usually happens because the user interrupted the model before we responded to one of its tool calls - // and then the user sent a follow-up message. - let missing_calls = { - prompt - .input - .iter() - .filter_map(|ri| match ri { - ResponseItem::FunctionCall { call_id, .. } => Some(call_id), - ResponseItem::LocalShellCall { - call_id: Some(call_id), - .. - } => Some(call_id), - ResponseItem::CustomToolCall { call_id, .. } => Some(call_id), - _ => None, - }) - .filter_map(|call_id| { - if completed_call_ids.contains(&call_id) { - None - } else { - Some(call_id.clone()) - } - }) - .map(|call_id| ResponseItem::CustomToolCallOutput { - call_id, - output: "aborted".to_string(), - }) - .collect::>() - }; - let prompt: Cow = if missing_calls.is_empty() { - Cow::Borrowed(prompt) - } else { - // Add the synthetic aborted missing calls to the beginning of the input to ensure all call ids have responses. - let input = [missing_calls, prompt.input.clone()].concat(); - Cow::Owned(Prompt { - input, - ..prompt.clone() - }) - }; - let rollout_item = RolloutItem::TurnContext(TurnContextItem { cwd: turn_context.cwd.clone(), approval_policy: turn_context.approval_policy, @@ -2130,28 +1993,40 @@ async fn try_run_turn( effort: turn_context.client.get_reasoning_effort(), summary: turn_context.client.get_reasoning_summary(), }); + sess.persist_rollout_items(&[rollout_item]).await; let mut stream = turn_context .client .clone() - .stream_with_task_kind(prompt.as_ref(), task_kind) - .await?; + .stream(prompt) + .or_cancel(&cancellation_token) + .await??; let tool_runtime = ToolCallRuntime::new( Arc::clone(&router), Arc::clone(&sess), Arc::clone(&turn_context), Arc::clone(&turn_diff_tracker), - sub_id.to_string(), ); let mut output: FuturesOrdered>> = FuturesOrdered::new(); + let mut active_item: Option = None; + loop { // Poll the next item from the model stream. We must inspect *both* Ok and Err // cases so that transient stream failures (e.g., dropped SSE connection before // `response.completed`) bubble up and trigger the caller's retry logic. - let event = stream.next().await; + let event = match stream.next().or_cancel(&cancellation_token).await { + Ok(event) => event, + Err(codex_async_utils::CancelErr::Cancelled) => { + let processed_items = output.try_collect().await?; + return Err(CodexErr::TurnAborted { + dangling_artifacts: processed_items, + }); + } + }; + let event = match event { Some(res) => res?, None => { @@ -2169,12 +2044,14 @@ async fn try_run_turn( match event { ResponseEvent::Created => {} ResponseEvent::OutputItemDone(item) => { + let previously_active_item = active_item.take(); match ToolRouter::build_tool_call(sess.as_ref(), item.clone()) { Ok(Some(call)) => { let payload_preview = call.payload.log_payload().into_owned(); tracing::info!("ToolCall: {} {}", call.tool_name, payload_preview); - let response = tool_runtime.handle_tool_call(call); + let response = + tool_runtime.handle_tool_call(call, cancellation_token.child_token()); output.push_back( async move { @@ -2187,14 +2064,19 @@ async fn try_run_turn( ); } Ok(None) => { - let response = handle_non_tool_response_item( - Arc::clone(&sess), - Arc::clone(&turn_context), - sub_id, - item.clone(), - ) - .await?; - add_completed(ProcessedResponseItem { item, response }); + if let Some(turn_item) = handle_non_tool_response_item(&item).await { + if previously_active_item.is_none() { + sess.emit_turn_item_started(&turn_context, &turn_item).await; + } + + sess.emit_turn_item_completed(&turn_context, turn_item) + .await; + } + + add_completed(ProcessedResponseItem { + item, + response: None, + }); } Err(FunctionCallError::MissingLocalShellCallId) => { let msg = "LocalShellCall without call_id or id"; @@ -2208,7 +2090,7 @@ async fn try_run_turn( call_id: String::new(), output: FunctionCallOutputPayload { content: msg.to_string(), - success: None, + ..Default::default() }, }; add_completed(ProcessedResponseItem { @@ -2216,12 +2098,13 @@ async fn try_run_turn( response: Some(response), }); } - Err(FunctionCallError::RespondToModel(message)) => { + Err(FunctionCallError::RespondToModel(message)) + | Err(FunctionCallError::Denied(message)) => { let response = ResponseInputItem::FunctionCallOutput { call_id: String::new(), output: FunctionCallOutputPayload { content: message, - success: None, + ..Default::default() }, }; add_completed(ProcessedResponseItem { @@ -2234,40 +2117,33 @@ async fn try_run_turn( } } } - ResponseEvent::WebSearchCallBegin { call_id } => { - let _ = sess - .tx_event - .send(Event { - id: sub_id.to_string(), - msg: EventMsg::WebSearchBegin(WebSearchBeginEvent { call_id }), - }) - .await; + ResponseEvent::OutputItemAdded(item) => { + if let Some(turn_item) = handle_non_tool_response_item(&item).await { + let tracked_item = turn_item.clone(); + sess.emit_turn_item_started(&turn_context, &turn_item).await; + + active_item = Some(tracked_item); + } } ResponseEvent::RateLimits(snapshot) => { // Update internal state with latest rate limits, but defer sending until // token usage is available to avoid duplicate TokenCount events. - sess.update_rate_limits(sub_id, snapshot).await; + sess.update_rate_limits(&turn_context, snapshot).await; } ResponseEvent::Completed { response_id: _, token_usage, } => { - sess.update_token_usage_info(sub_id, turn_context.as_ref(), token_usage.as_ref()) + sess.update_token_usage_info(&turn_context, token_usage.as_ref()) .await; - - let processed_items: Vec = output.try_collect().await?; - + let processed_items = output.try_collect().await?; let unified_diff = { let mut tracker = turn_diff_tracker.lock().await; tracker.get_unified_diff() }; if let Ok(Some(unified_diff)) = unified_diff { let msg = EventMsg::TurnDiff(TurnDiffEvent { unified_diff }); - let event = Event { - id: sub_id.to_string(), - msg, - }; - sess.send_event(event).await; + sess.send_event(&turn_context, msg).await; } let result = TurnRunResult { @@ -2280,79 +2156,69 @@ async fn try_run_turn( ResponseEvent::OutputTextDelta(delta) => { // In review child threads, suppress assistant text deltas; the // UI will show a selection popup from the final ReviewOutput. - if !turn_context.is_review_mode { - let event = Event { - id: sub_id.to_string(), - msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent { delta }), + if let Some(active) = active_item.as_ref() { + let event = AgentMessageContentDeltaEvent { + thread_id: sess.conversation_id.to_string(), + turn_id: turn_context.sub_id.clone(), + item_id: active.id(), + delta: delta.clone(), }; - sess.send_event(event).await; + sess.send_event(&turn_context, EventMsg::AgentMessageContentDelta(event)) + .await; } else { - trace!("suppressing OutputTextDelta in review mode"); + error_or_panic("ReasoningSummaryDelta without active item".to_string()); } } ResponseEvent::ReasoningSummaryDelta(delta) => { - let event = Event { - id: sub_id.to_string(), - msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { delta }), - }; - sess.send_event(event).await; + if let Some(active) = active_item.as_ref() { + let event = ReasoningContentDeltaEvent { + thread_id: sess.conversation_id.to_string(), + turn_id: turn_context.sub_id.clone(), + item_id: active.id(), + delta: delta.clone(), + }; + sess.send_event(&turn_context, EventMsg::ReasoningContentDelta(event)) + .await; + } else { + error_or_panic("ReasoningSummaryDelta without active item".to_string()); + } } ResponseEvent::ReasoningSummaryPartAdded => { - let event = Event { - id: sub_id.to_string(), - msg: EventMsg::AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent {}), - }; - sess.send_event(event).await; + let event = + EventMsg::AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent {}); + sess.send_event(&turn_context, event).await; } ResponseEvent::ReasoningContentDelta(delta) => { - if sess.show_raw_agent_reasoning() { - let event = Event { - id: sub_id.to_string(), - msg: EventMsg::AgentReasoningRawContentDelta( - AgentReasoningRawContentDeltaEvent { delta }, - ), + if let Some(active) = active_item.as_ref() { + let event = ReasoningRawContentDeltaEvent { + thread_id: sess.conversation_id.to_string(), + turn_id: turn_context.sub_id.clone(), + item_id: active.id(), + delta: delta.clone(), }; - sess.send_event(event).await; + sess.send_event(&turn_context, EventMsg::ReasoningRawContentDelta(event)) + .await; + } else { + error_or_panic("ReasoningRawContentDelta without active item".to_string()); } } } } } -async fn handle_non_tool_response_item( - sess: Arc, - turn_context: Arc, - sub_id: &str, - item: ResponseItem, -) -> CodexResult> { +async fn handle_non_tool_response_item(item: &ResponseItem) -> Option { debug!(?item, "Output item"); - match &item { + match item { ResponseItem::Message { .. } | ResponseItem::Reasoning { .. } - | ResponseItem::WebSearchCall { .. } => { - let msgs = match &item { - ResponseItem::Message { .. } if turn_context.is_review_mode => { - trace!("suppressing assistant Message in review mode"); - Vec::new() - } - _ => map_response_item_to_event_messages(&item, sess.show_raw_agent_reasoning()), - }; - for msg in msgs { - let event = Event { - id: sub_id.to_string(), - msg, - }; - sess.send_event(event).await; - } - } + | ResponseItem::WebSearchCall { .. } => parse_turn_item(item), ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. } => { debug!("unexpected tool output from stream"); + None } - _ => {} + _ => None, } - - Ok(None) } pub(super) fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) -> Option { @@ -2374,100 +2240,61 @@ pub(super) fn get_last_assistant_message_from_turn(responses: &[ResponseItem]) - } }) } -fn convert_call_tool_result_to_function_call_output_payload( - call_tool_result: &CallToolResult, -) -> FunctionCallOutputPayload { - let CallToolResult { - content, - is_error, - structured_content, - } = call_tool_result; - // In terms of what to send back to the model, we prefer structured_content, - // if available, and fallback to content, otherwise. - let mut is_success = is_error != &Some(true); - let content = if let Some(structured_content) = structured_content - && structured_content != &serde_json::Value::Null - && let Ok(serialized_structured_content) = serde_json::to_string(&structured_content) +fn mcp_init_error_display( + server_name: &str, + entry: Option<&McpAuthStatusEntry>, + err: &anyhow::Error, +) -> String { + if let Some(McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + http_headers, + .. + }) = &entry.map(|entry| &entry.config.transport) + && url == "https://api.githubcopilot.com/mcp/" + && bearer_token_env_var.is_none() + && http_headers.as_ref().map(HashMap::is_empty).unwrap_or(true) { - serialized_structured_content - } else { - match serde_json::to_string(&content) { - Ok(serialized_content) => serialized_content, - Err(err) => { - // If we could not serialize either content or structured_content to - // JSON, flag this as an error. - is_success = false; - err.to_string() - } + // GitHub only supports OAUth for first party MCP clients. + // That means that the user has to specify a personal access token either via bearer_token_env_var or http_headers. + // https://github.com/github/github-mcp-server/issues/921#issuecomment-3221026448 + format!( + "GitHub MCP does not support OAuth. Log in by adding a personal access token (https://github.com/settings/personal-access-tokens) to your environment and config.toml:\n[mcp_servers.{server_name}]\nbearer_token_env_var = CODEX_GITHUB_PERSONAL_ACCESS_TOKEN" + ) + } else if is_mcp_client_auth_required_error(err) { + format!( + "The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}`." + ) + } else if is_mcp_client_startup_timeout_error(err) { + let startup_timeout_secs = match entry { + Some(entry) => match entry.config.startup_timeout_sec { + Some(timeout) => timeout, + None => DEFAULT_STARTUP_TIMEOUT, + }, + None => DEFAULT_STARTUP_TIMEOUT, } - }; - - FunctionCallOutputPayload { - content, - success: Some(is_success), + .as_secs(); + format!( + "MCP client for `{server_name}` timed out after {startup_timeout_secs} seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.{server_name}]\nstartup_timeout_sec = XX" + ) + } else { + format!("MCP client for `{server_name}` failed to start: {err:#}") } } -/// Emits an ExitedReviewMode Event with optional ReviewOutput, -/// and records a developer message with the review output. -pub(crate) async fn exit_review_mode( - session: Arc, - task_sub_id: String, - review_output: Option, -) { - let event = Event { - id: task_sub_id, - msg: EventMsg::ExitedReviewMode(ExitedReviewModeEvent { - review_output: review_output.clone(), - }), - }; - session.send_event(event).await; - - let mut user_message = String::new(); - if let Some(out) = review_output { - let mut findings_str = String::new(); - let text = out.overall_explanation.trim(); - if !text.is_empty() { - findings_str.push_str(text); - } - if !out.findings.is_empty() { - let block = format_review_findings_block(&out.findings, None); - findings_str.push_str(&format!("\n{block}")); - } - user_message.push_str(&format!( - r#" - User initiated a review task. Here's the full review output from reviewer model. User may select one or more comments to resolve. - review - - {findings_str} - - -"#)); - } else { - user_message.push_str(r#" - User initiated a review task, but was interrupted. If user asks about this, tell them to re-initiate a review with `/review` and wait for it to complete. - review - - None. - - -"#); - } - - session - .record_conversation_items(&[ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { text: user_message }], - }]) - .await; +fn is_mcp_client_auth_required_error(error: &anyhow::Error) -> bool { + // StreamableHttpError::AuthRequired from the MCP SDK. + error.to_string().contains("Auth required") } -use crate::executor::errors::ExecError; -use crate::executor::linkers::PreparedExec; -use crate::tools::context::ApplyPatchCommandContext; -use crate::tools::context::ExecCommandContext; +fn is_mcp_client_startup_timeout_error(error: &anyhow::Error) -> bool { + let error_message = error.to_string(); + error_message.contains("request timed out") + || error_message.contains("timed out handshaking with MCP server") +} + +use crate::features::Features; #[cfg(test)] pub(crate) use tests::make_session_and_context; @@ -2476,6 +2303,11 @@ mod tests { use super::*; use crate::config::ConfigOverrides; use crate::config::ConfigToml; + use crate::config::types::McpServerConfig; + use crate::config::types::McpServerTransportConfig; + use crate::exec::ExecToolCallOutput; + use crate::mcp::auth::McpAuthStatusEntry; + use crate::tools::format_exec_output_str; use crate::protocol::CompactedItem; use crate::protocol::InitialHistory; @@ -2483,16 +2315,19 @@ mod tests { use crate::state::TaskKind; use crate::tasks::SessionTask; use crate::tasks::SessionTaskContext; - use crate::tools::MODEL_FORMAT_HEAD_LINES; - use crate::tools::MODEL_FORMAT_MAX_BYTES; - use crate::tools::MODEL_FORMAT_MAX_LINES; - use crate::tools::MODEL_FORMAT_TAIL_LINES; use crate::tools::ToolRouter; - use crate::tools::handle_container_exec_with_params; + use crate::tools::context::ToolInvocation; + use crate::tools::context::ToolOutput; + use crate::tools::context::ToolPayload; + use crate::tools::handlers::ShellHandler; + use crate::tools::registry::ToolHandler; use crate::turn_diff_tracker::TurnDiffTracker; use codex_app_server_protocol::AuthMode; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; + use codex_protocol::protocol::McpAuthStatus; + use std::time::Duration; + use tokio::time::sleep; use mcp_types::ContentBlock; use mcp_types::TextContent; @@ -2502,8 +2337,6 @@ mod tests { use std::path::PathBuf; use std::sync::Arc; use std::time::Duration as StdDuration; - use tokio::time::Duration; - use tokio::time::sleep; #[test] fn reconstruct_history_matches_live_compactions() { @@ -2520,16 +2353,17 @@ mod tests { let (session, turn_context) = make_session_and_context(); let (rollout_items, expected) = sample_rollout(&session, &turn_context); - tokio_test::block_on(session.record_initial_history( - &turn_context, - InitialHistory::Resumed(ResumedHistory { + tokio_test::block_on(session.record_initial_history(InitialHistory::Resumed( + ResumedHistory { conversation_id: ConversationId::default(), history: rollout_items, rollout_path: PathBuf::from("/tmp/resume.jsonl"), - }), - )); + }, + ))); - let actual = tokio_test::block_on(async { session.state.lock().await.history_snapshot() }); + let actual = tokio_test::block_on(async { + session.state.lock().await.clone_history().get_history() + }); assert_eq!(expected, actual); } @@ -2538,11 +2372,11 @@ mod tests { let (session, turn_context) = make_session_and_context(); let (rollout_items, expected) = sample_rollout(&session, &turn_context); - tokio_test::block_on( - session.record_initial_history(&turn_context, InitialHistory::Forked(rollout_items)), - ); + tokio_test::block_on(session.record_initial_history(InitialHistory::Forked(rollout_items))); - let actual = tokio_test::block_on(async { session.state.lock().await.history_snapshot() }); + let actual = tokio_test::block_on(async { + session.state.lock().await.clone_history().get_history() + }); assert_eq!(expected, actual); } @@ -2558,7 +2392,7 @@ mod tests { })), }; - let got = convert_call_tool_result_to_function_call_output_payload(&ctr); + let got = FunctionCallOutputPayload::from(&ctr); let expected = FunctionCallOutputPayload { content: serde_json::to_string(&json!({ "ok": true, @@ -2566,100 +2400,12 @@ mod tests { })) .unwrap(), success: Some(true), + ..Default::default() }; assert_eq!(expected, got); } - #[test] - fn model_truncation_head_tail_by_lines() { - // Build 400 short lines so line-count limit, not byte budget, triggers truncation - let lines: Vec = (1..=400).map(|i| format!("line{i}")).collect(); - let full = lines.join("\n"); - - let exec = ExecToolCallOutput { - exit_code: 0, - stdout: StreamOutput::new(String::new()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new(full), - duration: StdDuration::from_secs(1), - timed_out: false, - }; - - let out = format_exec_output_str(&exec); - - // Strip truncation header if present for subsequent assertions - let body = out - .strip_prefix("Total output lines: ") - .and_then(|rest| rest.split_once("\n\n").map(|x| x.1)) - .unwrap_or(out.as_str()); - - // Expect elision marker with correct counts - let omitted = 400 - MODEL_FORMAT_MAX_LINES; // 144 - let marker = format!("\n[... omitted {omitted} of 400 lines ...]\n\n"); - assert!(out.contains(&marker), "missing marker: {out}"); - - // Validate head and tail - let parts: Vec<&str> = body.split(&marker).collect(); - assert_eq!(parts.len(), 2, "expected one marker split"); - let head = parts[0]; - let tail = parts[1]; - - let expected_head: String = (1..=MODEL_FORMAT_HEAD_LINES) - .map(|i| format!("line{i}")) - .collect::>() - .join("\n"); - assert!(head.starts_with(&expected_head), "head mismatch"); - - let expected_tail: String = ((400 - MODEL_FORMAT_TAIL_LINES + 1)..=400) - .map(|i| format!("line{i}")) - .collect::>() - .join("\n"); - assert!(tail.ends_with(&expected_tail), "tail mismatch"); - } - - #[test] - fn model_truncation_respects_byte_budget() { - // Construct a large output (about 100kB) so byte budget dominates - let big_line = "x".repeat(100); - let full = std::iter::repeat_n(big_line, 1000) - .collect::>() - .join("\n"); - - let exec = ExecToolCallOutput { - exit_code: 0, - stdout: StreamOutput::new(String::new()), - stderr: StreamOutput::new(String::new()), - aggregated_output: StreamOutput::new(full.clone()), - duration: StdDuration::from_secs(1), - timed_out: false, - }; - - let out = format_exec_output_str(&exec); - // Keep strict budget on the truncated body (excluding header) - let body = out - .strip_prefix("Total output lines: ") - .and_then(|rest| rest.split_once("\n\n").map(|x| x.1)) - .unwrap_or(out.as_str()); - assert!(body.len() <= MODEL_FORMAT_MAX_BYTES, "exceeds byte budget"); - assert!(out.contains("omitted"), "should contain elision marker"); - - // Ensure head and tail are drawn from the original - assert!(full.starts_with(body.chars().take(8).collect::().as_str())); - assert!( - full.ends_with( - body.chars() - .rev() - .take(8) - .collect::() - .chars() - .rev() - .collect::() - .as_str() - ) - ); - } - #[test] fn includes_timed_out_message() { let exec = ExecToolCallOutput { @@ -2687,11 +2433,12 @@ mod tests { structured_content: Some(serde_json::Value::Null), }; - let got = convert_call_tool_result_to_function_call_output_payload(&ctr); + let got = FunctionCallOutputPayload::from(&ctr); let expected = FunctionCallOutputPayload { content: serde_json::to_string(&vec![text_block("hello"), text_block("world")]) .unwrap(), success: Some(true), + ..Default::default() }; assert_eq!(expected, got); @@ -2705,10 +2452,11 @@ mod tests { structured_content: Some(json!({ "message": "bad" })), }; - let got = convert_call_tool_result_to_function_call_output_payload(&ctr); + let got = FunctionCallOutputPayload::from(&ctr); let expected = FunctionCallOutputPayload { content: serde_json::to_string(&json!({ "message": "bad" })).unwrap(), success: Some(false), + ..Default::default() }; assert_eq!(expected, got); @@ -2722,10 +2470,11 @@ mod tests { structured_content: None, }; - let got = convert_call_tool_result_to_function_call_output_payload(&ctr); + let got = FunctionCallOutputPayload::from(&ctr); let expected = FunctionCallOutputPayload { content: serde_json::to_string(&vec![text_block("alpha")]).unwrap(), success: Some(true), + ..Default::default() }; assert_eq!(expected, got); @@ -2764,53 +2513,61 @@ mod tests { let config = Arc::new(config); let conversation_id = ConversationId::default(); let otel_event_manager = otel_event_manager(conversation_id, config.as_ref()); - let client = ModelClient::new( - config.clone(), - None, - otel_event_manager, - config.model_provider.clone(), - config.model_reasoning_effort, - config.model_reasoning_summary, - conversation_id, + let auth_manager = AuthManager::shared( + config.cwd.clone(), + false, + config.cli_auth_credentials_store_mode, ); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_family: &config.model_family, - features: &config.features, - }); - let turn_context = TurnContext { - client, - cwd: config.cwd.clone(), - base_instructions: config.base_instructions.clone(), + + let session_configuration = SessionConfiguration { + provider: config.model_provider.clone(), + model: config.model.clone(), + model_reasoning_effort: config.model_reasoning_effort, + model_reasoning_summary: config.model_reasoning_summary, + developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), + base_instructions: config.base_instructions.clone(), + compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy, sandbox_policy: config.sandbox_policy.clone(), - shell_environment_policy: config.shell_environment_policy.clone(), - tools_config, - is_review_mode: false, - final_output_json_schema: None, + cwd: config.cwd.clone(), + original_config_do_not_use: Arc::clone(&config), + features: Features::default(), + session_source: SessionSource::Exec, }; + + let state = SessionState::new(session_configuration.clone()); + let services = SessionServices { mcp_connection_manager: McpConnectionManager::default(), - session_manager: ExecSessionManager::default(), unified_exec_manager: UnifiedExecSessionManager::default(), - notifier: UserNotifier::default(), + notifier: UserNotifier::new(None), rollout: Mutex::new(None), user_shell: shell::Shell::Unknown, show_raw_agent_reasoning: config.show_raw_agent_reasoning, - executor: Executor::new(ExecutorConfig::new( - turn_context.sandbox_policy.clone(), - turn_context.cwd.clone(), - None, - )), + auth_manager: Arc::clone(&auth_manager), + otel_event_manager: otel_event_manager.clone(), + tool_approvals: Mutex::new(ApprovalStore::default()), }; + + let turn_context = Session::make_turn_context( + Some(Arc::clone(&auth_manager)), + &otel_event_manager, + session_configuration.provider.clone(), + &session_configuration, + conversation_id, + "turn_id".to_string(), + ); + let session = Session { conversation_id, tx_event, - state: Mutex::new(SessionState::new()), + state: Mutex::new(state), active_turn: Mutex::new(None), services, next_internal_sub_id: AtomicU64::new(0), }; + (session, turn_context) } @@ -2832,96 +2589,136 @@ mod tests { let config = Arc::new(config); let conversation_id = ConversationId::default(); let otel_event_manager = otel_event_manager(conversation_id, config.as_ref()); - let client = ModelClient::new( - config.clone(), - None, - otel_event_manager, - config.model_provider.clone(), - config.model_reasoning_effort, - config.model_reasoning_summary, - conversation_id, + let auth_manager = AuthManager::shared( + config.cwd.clone(), + false, + config.cli_auth_credentials_store_mode, ); - let tools_config = ToolsConfig::new(&ToolsConfigParams { - model_family: &config.model_family, - features: &config.features, - }); - let turn_context = Arc::new(TurnContext { - client, - cwd: config.cwd.clone(), - base_instructions: config.base_instructions.clone(), + + let session_configuration = SessionConfiguration { + provider: config.model_provider.clone(), + model: config.model.clone(), + model_reasoning_effort: config.model_reasoning_effort, + model_reasoning_summary: config.model_reasoning_summary, + developer_instructions: config.developer_instructions.clone(), user_instructions: config.user_instructions.clone(), + base_instructions: config.base_instructions.clone(), + compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy, sandbox_policy: config.sandbox_policy.clone(), - shell_environment_policy: config.shell_environment_policy.clone(), - tools_config, - is_review_mode: false, - final_output_json_schema: None, - }); + cwd: config.cwd.clone(), + original_config_do_not_use: Arc::clone(&config), + features: Features::default(), + session_source: SessionSource::Exec, + }; + + let state = SessionState::new(session_configuration.clone()); + let services = SessionServices { mcp_connection_manager: McpConnectionManager::default(), - session_manager: ExecSessionManager::default(), unified_exec_manager: UnifiedExecSessionManager::default(), - notifier: UserNotifier::default(), + notifier: UserNotifier::new(None), rollout: Mutex::new(None), user_shell: shell::Shell::Unknown, show_raw_agent_reasoning: config.show_raw_agent_reasoning, - executor: Executor::new(ExecutorConfig::new( - config.sandbox_policy.clone(), - config.cwd.clone(), - None, - )), + auth_manager: Arc::clone(&auth_manager), + otel_event_manager: otel_event_manager.clone(), + tool_approvals: Mutex::new(ApprovalStore::default()), }; + + let turn_context = Arc::new(Session::make_turn_context( + Some(Arc::clone(&auth_manager)), + &otel_event_manager, + session_configuration.provider.clone(), + &session_configuration, + conversation_id, + "turn_id".to_string(), + )); + let session = Arc::new(Session { conversation_id, tx_event, - state: Mutex::new(SessionState::new()), + state: Mutex::new(state), active_turn: Mutex::new(None), services, next_internal_sub_id: AtomicU64::new(0), }); + (session, turn_context, rx_event) } #[derive(Clone, Copy)] - struct NeverEndingTask(TaskKind); + struct NeverEndingTask { + kind: TaskKind, + listen_to_cancellation_token: bool, + } #[async_trait::async_trait] impl SessionTask for NeverEndingTask { fn kind(&self) -> TaskKind { - self.0 + self.kind } async fn run( self: Arc, _session: Arc, _ctx: Arc, - _sub_id: String, - _input: Vec, + _input: Vec, + cancellation_token: CancellationToken, ) -> Option { + if self.listen_to_cancellation_token { + cancellation_token.cancelled().await; + return None; + } loop { sleep(Duration::from_secs(60)).await; } } - - async fn abort(&self, session: Arc, sub_id: &str) { - if let TaskKind::Review = self.0 { - exit_review_mode(session.clone_session(), sub_id.to_string(), None).await; - } - } } - #[tokio::test] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[test_log::test] async fn abort_regular_task_emits_turn_aborted_only() { let (sess, tc, rx) = make_session_and_context_with_rx(); - let sub_id = "sub-regular".to_string(); - let input = vec![InputItem::Text { + let input = vec![UserInput::Text { text: "hello".to_string(), }]; sess.spawn_task( Arc::clone(&tc), - sub_id.clone(), input, - NeverEndingTask(TaskKind::Regular), + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: false, + }, + ) + .await; + + sess.abort_all_tasks(TurnAbortReason::Interrupted).await; + + let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for event") + .expect("event"); + match evt.msg { + EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), + other => panic!("unexpected event: {other:?}"), + } + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn abort_gracefuly_emits_turn_aborted_only() { + let (sess, tc, rx) = make_session_and_context_with_rx(); + let input = vec![UserInput::Text { + text: "hello".to_string(), + }]; + sess.spawn_task( + Arc::clone(&tc), + input, + NeverEndingTask { + kind: TaskKind::Regular, + listen_to_cancellation_token: true, + }, ) .await; @@ -2935,35 +2732,48 @@ mod tests { assert!(rx.try_recv().is_err()); } - #[tokio::test] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn abort_review_task_emits_exited_then_aborted_and_records_history() { let (sess, tc, rx) = make_session_and_context_with_rx(); - let sub_id = "sub-review".to_string(); - let input = vec![InputItem::Text { + let input = vec![UserInput::Text { text: "start review".to_string(), }]; - sess.spawn_task( - Arc::clone(&tc), - sub_id.clone(), - input, - NeverEndingTask(TaskKind::Review), - ) - .await; + sess.spawn_task(Arc::clone(&tc), input, ReviewTask).await; sess.abort_all_tasks(TurnAbortReason::Interrupted).await; - let first = rx.recv().await.expect("first event"); - match first.msg { - EventMsg::ExitedReviewMode(ev) => assert!(ev.review_output.is_none()), - other => panic!("unexpected first event: {other:?}"), + // Drain events until we observe ExitedReviewMode; earlier + // RawResponseItem entries (e.g., environment context) may arrive first. + loop { + let evt = tokio::time::timeout(std::time::Duration::from_secs(1), rx.recv()) + .await + .expect("timeout waiting for first event") + .expect("first event"); + match evt.msg { + EventMsg::ExitedReviewMode(ev) => { + assert!(ev.review_output.is_none()); + break; + } + // Ignore any non-critical events before exit. + _ => continue, + } } - let second = rx.recv().await.expect("second event"); - match second.msg { - EventMsg::TurnAborted(e) => assert_eq!(TurnAbortReason::Interrupted, e.reason), - other => panic!("unexpected second event: {other:?}"), + loop { + let evt = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for next event") + .expect("event"); + match evt.msg { + EventMsg::RawResponseItem(_) => continue, + EventMsg::TurnAborted(e) => { + assert_eq!(TurnAbortReason::Interrupted, e.reason); + break; + } + other => panic!("unexpected second event: {other:?}"), + } } - let history = sess.history_snapshot().await; + let history = sess.clone_history().await.get_history(); let found = history.iter().any(|item| match item { ResponseItem::Message { role, content, .. } if role == "user" => { content.iter().any(|ci| match ci { @@ -3007,7 +2817,6 @@ mod tests { Arc::clone(&session), Arc::clone(&turn_context), tracker, - "sub-id".to_string(), call, ) .await @@ -3026,7 +2835,7 @@ mod tests { turn_context: &TurnContext, ) -> (Vec, Vec) { let mut rollout_items = Vec::new(); - let mut live_history = ConversationHistory::new(); + let mut live_history = ContextManager::new(); let initial_context = session.build_initial_context(turn_context); for item in &initial_context { @@ -3055,7 +2864,7 @@ mod tests { rollout_items.push(RolloutItem::ResponseItem(assistant1.clone())); let summary1 = "summary one"; - let snapshot1 = live_history.contents(); + let snapshot1 = live_history.get_history(); let user_messages1 = collect_user_messages(&snapshot1); let rebuilt1 = build_compacted_history( session.build_initial_context(turn_context), @@ -3088,7 +2897,7 @@ mod tests { rollout_items.push(RolloutItem::ResponseItem(assistant2.clone())); let summary2 = "summary two"; - let snapshot2 = live_history.contents(); + let snapshot2 = live_history.get_history(); let user_messages2 = collect_user_messages(&snapshot2); let rebuilt2 = build_compacted_history( session.build_initial_context(turn_context), @@ -3120,7 +2929,7 @@ mod tests { live_history.record_items(std::iter::once(&assistant3)); rollout_items.push(RolloutItem::ResponseItem(assistant3.clone())); - (rollout_items, live_history.contents()) + (rollout_items, live_history.get_history()) } #[tokio::test] @@ -3156,6 +2965,7 @@ mod tests { env: HashMap::new(), with_escalated_permissions: Some(true), justification: Some("test".to_string()), + arg0: None, }; let params2 = ExecParams { @@ -3166,19 +2976,28 @@ mod tests { let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); let tool_name = "shell"; - let sub_id = "test-sub".to_string(); let call_id = "test-call".to_string(); - let resp = handle_container_exec_with_params( - tool_name, - params, - Arc::clone(&session), - Arc::clone(&turn_context), - Arc::clone(&turn_diff_tracker), - sub_id, - call_id, - ) - .await; + let handler = ShellHandler; + let resp = handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + tracker: Arc::clone(&turn_diff_tracker), + call_id, + tool_name: tool_name.to_string(), + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "command": params.command.clone(), + "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), + "timeout_ms": params.timeout_ms, + "with_escalated_permissions": params.with_escalated_permissions, + "justification": params.justification.clone(), + }) + .to_string(), + }, + }) + .await; let Err(FunctionCallError::RespondToModel(output)) = resp else { panic!("expected error result"); @@ -3197,18 +3016,30 @@ mod tests { .expect("unique turn context Arc") .sandbox_policy = SandboxPolicy::DangerFullAccess; - let resp2 = handle_container_exec_with_params( - tool_name, - params2, - Arc::clone(&session), - Arc::clone(&turn_context), - Arc::clone(&turn_diff_tracker), - "test-sub".to_string(), - "test-call-2".to_string(), - ) - .await; + let resp2 = handler + .handle(ToolInvocation { + session: Arc::clone(&session), + turn: Arc::clone(&turn_context), + tracker: Arc::clone(&turn_diff_tracker), + call_id: "test-call-2".to_string(), + tool_name: tool_name.to_string(), + payload: ToolPayload::Function { + arguments: serde_json::json!({ + "command": params2.command.clone(), + "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), + "timeout_ms": params2.timeout_ms, + "with_escalated_permissions": params2.with_escalated_permissions, + "justification": params2.justification.clone(), + }) + .to_string(), + }, + }) + .await; - let output = resp2.expect("expected Ok result"); + let output = match resp2.expect("expected Ok result") { + ToolOutput::Function { content, .. } => content, + _ => panic!("unexpected tool output"), + }; #[derive(Deserialize, PartialEq, Eq, Debug)] struct ResponseExecMetadata { @@ -3227,4 +3058,89 @@ mod tests { pretty_assertions::assert_eq!(exec_output.metadata, ResponseExecMetadata { exit_code: 0 }); assert!(exec_output.output.contains("hi")); } + + #[test] + fn mcp_init_error_display_prompts_for_github_pat() { + let server_name = "github"; + let entry = McpAuthStatusEntry { + config: McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://api.githubcopilot.com/mcp/".to_string(), + bearer_token_env_var: None, + http_headers: None, + env_http_headers: None, + }, + enabled: true, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + }, + auth_status: McpAuthStatus::Unsupported, + }; + let err = anyhow::anyhow!("OAuth is unsupported"); + + let display = mcp_init_error_display(server_name, Some(&entry), &err); + + let expected = format!( + "GitHub MCP does not support OAuth. Log in by adding a personal access token (https://github.com/settings/personal-access-tokens) to your environment and config.toml:\n[mcp_servers.{server_name}]\nbearer_token_env_var = CODEX_GITHUB_PERSONAL_ACCESS_TOKEN" + ); + + assert_eq!(expected, display); + } + + #[test] + fn mcp_init_error_display_prompts_for_login_when_auth_required() { + let server_name = "example"; + let err = anyhow::anyhow!("Auth required for server"); + + let display = mcp_init_error_display(server_name, None, &err); + + let expected = format!( + "The {server_name} MCP server is not logged in. Run `codex mcp login {server_name}`." + ); + + assert_eq!(expected, display); + } + + #[test] + fn mcp_init_error_display_reports_generic_errors() { + let server_name = "custom"; + let entry = McpAuthStatusEntry { + config: McpServerConfig { + transport: McpServerTransportConfig::StreamableHttp { + url: "https://example.com".to_string(), + bearer_token_env_var: Some("TOKEN".to_string()), + http_headers: None, + env_http_headers: None, + }, + enabled: true, + startup_timeout_sec: None, + tool_timeout_sec: None, + enabled_tools: None, + disabled_tools: None, + }, + auth_status: McpAuthStatus::Unsupported, + }; + let err = anyhow::anyhow!("boom"); + + let display = mcp_init_error_display(server_name, Some(&entry), &err); + + let expected = format!("MCP client for `{server_name}` failed to start: {err:#}"); + + assert_eq!(expected, display); + } + + #[test] + fn mcp_init_error_display_includes_startup_timeout_hint() { + let server_name = "slow"; + let err = anyhow::anyhow!("request timed out"); + + let display = mcp_init_error_display(server_name, None, &err); + + assert_eq!( + "MCP client for `slow` timed out after 10 seconds. Add or adjust `startup_timeout_sec` in your config.toml:\n[mcp_servers.slow]\nstartup_timeout_sec = XX", + display + ); + } } diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/codex/compact.rs index 93bbfa79c6..c07503663d 100644 --- a/codex-rs/core/src/codex/compact.rs +++ b/codex-rs/core/src/codex/compact.rs @@ -10,70 +10,56 @@ use crate::error::Result as CodexResult; use crate::protocol::AgentMessageEvent; use crate::protocol::CompactedItem; use crate::protocol::ErrorEvent; -use crate::protocol::Event; use crate::protocol::EventMsg; -use crate::protocol::InputItem; -use crate::protocol::InputMessageKind; use crate::protocol::TaskStartedEvent; use crate::protocol::TurnContextItem; -use crate::state::TaskKind; +use crate::protocol::WarningEvent; use crate::truncate::truncate_middle; use crate::util::backoff; -use askama::Template; +use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::RolloutItem; +use codex_protocol::user_input::UserInput; use futures::prelude::*; +use tracing::error; pub const SUMMARIZATION_PROMPT: &str = include_str!("../../templates/compact/prompt.md"); const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000; -#[derive(Template)] -#[template(path = "compact/history_bridge.md", escape = "none")] -struct HistoryBridgeTemplate<'a> { - user_messages_text: &'a str, - summary_text: &'a str, -} - pub(crate) async fn run_inline_auto_compact_task( sess: Arc, turn_context: Arc, ) { - let sub_id = sess.next_internal_sub_id(); - let input = vec![InputItem::Text { - text: SUMMARIZATION_PROMPT.to_string(), - }]; - run_compact_task_inner(sess, turn_context, sub_id, input).await; + let prompt = turn_context.compact_prompt().to_string(); + let input = vec![UserInput::Text { text: prompt }]; + run_compact_task_inner(sess, turn_context, input).await; } pub(crate) async fn run_compact_task( sess: Arc, turn_context: Arc, - sub_id: String, - input: Vec, + input: Vec, ) -> Option { - let start_event = Event { - id: sub_id.clone(), - msg: EventMsg::TaskStarted(TaskStartedEvent { - model_context_window: turn_context.client.get_model_context_window(), - }), - }; - sess.send_event(start_event).await; - run_compact_task_inner(sess.clone(), turn_context, sub_id.clone(), input).await; + let start_event = EventMsg::TaskStarted(TaskStartedEvent { + model_context_window: turn_context.client.get_model_context_window(), + }); + sess.send_event(&turn_context, start_event).await; + run_compact_task_inner(sess.clone(), turn_context, input).await; None } async fn run_compact_task_inner( sess: Arc, turn_context: Arc, - sub_id: String, - input: Vec, + input: Vec, ) { let initial_input_for_turn: ResponseInputItem = ResponseInputItem::from(input); - let mut turn_input = sess - .turn_input_with_history(vec![initial_input_for_turn.clone().into()]) - .await; + + let mut history = sess.clone_history().await; + history.record_items(&[initial_input_for_turn.into()]); + let mut truncated_count = 0usize; let max_retries = turn_context.client.get_provider().stream_max_retries(); @@ -90,18 +76,18 @@ async fn run_compact_task_inner( sess.persist_rollout_items(&[rollout_item]).await; loop { + let turn_input = history.get_history_for_prompt(); let prompt = Prompt { input: turn_input.clone(), ..Default::default() }; - let attempt_result = - drain_to_completed(&sess, turn_context.as_ref(), &sub_id, &prompt).await; + let attempt_result = drain_to_completed(&sess, turn_context.as_ref(), &prompt).await; match attempt_result { Ok(()) => { if truncated_count > 0 { sess.notify_background_event( - &sub_id, + turn_context.as_ref(), format!( "Trimmed {truncated_count} older conversation item(s) before compacting so the prompt fits the model context window." ), @@ -115,20 +101,20 @@ async fn run_compact_task_inner( } Err(e @ CodexErr::ContextWindowExceeded) => { if turn_input.len() > 1 { - turn_input.remove(0); + // Trim from the beginning to preserve cache (prefix-based) and keep recent messages intact. + error!( + "Context window exceeded while compacting; removing oldest history item. Error: {e}" + ); + history.remove_first_item(); truncated_count += 1; retries = 0; continue; } - sess.set_total_tokens_full(&sub_id, turn_context.as_ref()) - .await; - let event = Event { - id: sub_id.clone(), - msg: EventMsg::Error(ErrorEvent { - message: e.to_string(), - }), - }; - sess.send_event(event).await; + sess.set_total_tokens_full(turn_context.as_ref()).await; + let event = EventMsg::Error(ErrorEvent { + message: e.to_string(), + }); + sess.send_event(&turn_context, event).await; return; } Err(e) => { @@ -136,31 +122,35 @@ async fn run_compact_task_inner( retries += 1; let delay = backoff(retries); sess.notify_stream_error( - &sub_id, - format!("Re-connecting... {retries}/{max_retries}"), + turn_context.as_ref(), + format!("Reconnecting... {retries}/{max_retries}"), ) .await; tokio::time::sleep(delay).await; continue; } else { - let event = Event { - id: sub_id.clone(), - msg: EventMsg::Error(ErrorEvent { - message: e.to_string(), - }), - }; - sess.send_event(event).await; + let event = EventMsg::Error(ErrorEvent { + message: e.to_string(), + }); + sess.send_event(&turn_context, event).await; return; } } } } - let history_snapshot = sess.history_snapshot().await; + let history_snapshot = sess.clone_history().await.get_history(); let summary_text = get_last_assistant_message_from_turn(&history_snapshot).unwrap_or_default(); let user_messages = collect_user_messages(&history_snapshot); + let initial_context = sess.build_initial_context(turn_context.as_ref()); - let new_history = build_compacted_history(initial_context, &user_messages, &summary_text); + let mut new_history = build_compacted_history(initial_context, &user_messages, &summary_text); + let ghost_snapshots: Vec = history_snapshot + .iter() + .filter(|item| matches!(item, ResponseItem::GhostSnapshot { .. })) + .cloned() + .collect(); + new_history.extend(ghost_snapshots); sess.replace_history(new_history).await; let rollout_item = RolloutItem::Compacted(CompactedItem { @@ -168,13 +158,15 @@ async fn run_compact_task_inner( }); sess.persist_rollout_items(&[rollout_item]).await; - let event = Event { - id: sub_id.clone(), - msg: EventMsg::AgentMessage(AgentMessageEvent { - message: "Compact task completed".to_string(), - }), - }; - sess.send_event(event).await; + let event = EventMsg::AgentMessage(AgentMessageEvent { + message: "Compact task completed".to_string(), + }); + sess.send_event(&turn_context, event).await; + + let warning = EventMsg::Warning(WarningEvent { + message: "Heads up: Long conversations and multiple compactions can cause the model to be less accurate. Start new a new conversation when possible to keep conversations small and targeted.".to_string(), + }); + sess.send_event(&turn_context, warning).await; } pub fn content_items_to_text(content: &[ContentItem]) -> Option { @@ -199,71 +191,82 @@ pub fn content_items_to_text(content: &[ContentItem]) -> Option { pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec { items .iter() - .filter_map(|item| match item { - ResponseItem::Message { role, content, .. } if role == "user" => { - content_items_to_text(content) - } + .filter_map(|item| match crate::event_mapping::parse_turn_item(item) { + Some(TurnItem::UserMessage(user)) => Some(user.message()), _ => None, }) - .filter(|text| !is_session_prefix_message(text)) .collect() } -pub fn is_session_prefix_message(text: &str) -> bool { - matches!( - InputMessageKind::from(("user", text)), - InputMessageKind::UserInstructions | InputMessageKind::EnvironmentContext - ) -} - pub(crate) fn build_compacted_history( initial_context: Vec, user_messages: &[String], summary_text: &str, ) -> Vec { - let mut history = initial_context; - let mut user_messages_text = if user_messages.is_empty() { - "(none)".to_string() - } else { - user_messages.join("\n\n") - }; - // Truncate the concatenated prior user messages so the bridge message - // stays well under the context window (approx. 4 bytes/token). - let max_bytes = COMPACT_USER_MESSAGE_MAX_TOKENS * 4; - if user_messages_text.len() > max_bytes { - user_messages_text = truncate_middle(&user_messages_text, max_bytes).0; + build_compacted_history_with_limit( + initial_context, + user_messages, + summary_text, + COMPACT_USER_MESSAGE_MAX_TOKENS * 4, + ) +} + +fn build_compacted_history_with_limit( + mut history: Vec, + user_messages: &[String], + summary_text: &str, + max_bytes: usize, +) -> Vec { + let mut selected_messages: Vec = Vec::new(); + if max_bytes > 0 { + let mut remaining = max_bytes; + for message in user_messages.iter().rev() { + if remaining == 0 { + break; + } + if message.len() <= remaining { + selected_messages.push(message.clone()); + remaining = remaining.saturating_sub(message.len()); + } else { + let (truncated, _) = truncate_middle(message, remaining); + selected_messages.push(truncated); + break; + } + } + selected_messages.reverse(); } + + for message in &selected_messages { + history.push(ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: message.clone(), + }], + }); + } + let summary_text = if summary_text.is_empty() { "(no summary available)".to_string() } else { summary_text.to_string() }; - let Ok(bridge) = HistoryBridgeTemplate { - user_messages_text: &user_messages_text, - summary_text: &summary_text, - } - .render() else { - return vec![]; - }; + history.push(ResponseItem::Message { id: None, role: "user".to_string(), - content: vec![ContentItem::InputText { text: bridge }], + content: vec![ContentItem::InputText { text: summary_text }], }); + history } async fn drain_to_completed( sess: &Session, turn_context: &TurnContext, - sub_id: &str, prompt: &Prompt, ) -> CodexResult<()> { - let mut stream = turn_context - .client - .clone() - .stream_with_task_kind(prompt, TaskKind::Compact) - .await?; + let mut stream = turn_context.client.clone().stream(prompt).await?; loop { let maybe_event = stream.next().await; let Some(event) = maybe_event else { @@ -277,10 +280,10 @@ async fn drain_to_completed( sess.record_into_history(std::slice::from_ref(&item)).await; } Ok(ResponseEvent::RateLimits(snapshot)) => { - sess.update_rate_limits(sub_id, snapshot).await; + sess.update_rate_limits(turn_context, snapshot).await; } Ok(ResponseEvent::Completed { token_usage, .. }) => { - sess.update_token_usage_info(sub_id, turn_context, token_usage.as_ref()) + sess.update_token_usage_info(turn_context, token_usage.as_ref()) .await; return Ok(()); } @@ -338,21 +341,16 @@ mod tests { ResponseItem::Message { id: Some("user".to_string()), role: "user".to_string(), - content: vec![ - ContentItem::InputText { - text: "first".to_string(), - }, - ContentItem::OutputText { - text: "second".to_string(), - }, - ], + content: vec![ContentItem::InputText { + text: "first".to_string(), + }], }, ResponseItem::Other, ]; let collected = collect_user_messages(&items); - assert_eq!(vec!["first\nsecond".to_string()], collected); + assert_eq!(vec!["first".to_string()], collected); } #[test] @@ -362,7 +360,8 @@ mod tests { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: "do things".to_string(), + text: "# AGENTS.md instructions for project\n\n\ndo things\n" + .to_string(), }], }, ResponseItem::Message { @@ -388,35 +387,65 @@ mod tests { #[test] fn build_compacted_history_truncates_overlong_user_messages() { - // Prepare a very large prior user message so the aggregated - // `user_messages_text` exceeds the truncation threshold used by - // `build_compacted_history` (80k bytes). - let big = "X".repeat(200_000); - let history = build_compacted_history(Vec::new(), std::slice::from_ref(&big), "SUMMARY"); + // Use a small truncation limit so the test remains fast while still validating + // that oversized user content is truncated. + let max_bytes = 128; + let big = "X".repeat(max_bytes + 50); + let history = super::build_compacted_history_with_limit( + Vec::new(), + std::slice::from_ref(&big), + "SUMMARY", + max_bytes, + ); + assert_eq!(history.len(), 2); - // Expect exactly one bridge message added to history (plus any initial context we provided, which is none). - assert_eq!(history.len(), 1); + let truncated_message = &history[0]; + let summary_message = &history[1]; - // Extract the text content of the bridge message. - let bridge_text = match &history[0] { + let truncated_text = match truncated_message { ResponseItem::Message { role, content, .. } if role == "user" => { content_items_to_text(content).unwrap_or_default() } other => panic!("unexpected item in history: {other:?}"), }; - // The bridge should contain the truncation marker and not the full original payload. assert!( - bridge_text.contains("tokens truncated"), - "expected truncation marker in bridge message" + truncated_text.contains("tokens truncated"), + "expected truncation marker in truncated user message" ); assert!( - !bridge_text.contains(&big), - "bridge should not include the full oversized user text" + !truncated_text.contains(&big), + "truncated user message should not include the full oversized user text" ); + + let summary_text = match summary_message { + ResponseItem::Message { role, content, .. } if role == "user" => { + content_items_to_text(content).unwrap_or_default() + } + other => panic!("unexpected item in history: {other:?}"), + }; + assert_eq!(summary_text, "SUMMARY"); + } + + #[test] + fn build_compacted_history_appends_summary_message() { + let initial_context: Vec = Vec::new(); + let user_messages = vec!["first user message".to_string()]; + let summary_text = "summary text"; + + let history = build_compacted_history(initial_context, &user_messages, summary_text); assert!( - bridge_text.contains("SUMMARY"), - "bridge should include the provided summary text" + !history.is_empty(), + "expected compacted history to include summary" ); + + let last = history.last().expect("history should have a summary entry"); + let summary = match last { + ResponseItem::Message { role, content, .. } if role == "user" => { + content_items_to_text(content).unwrap_or_default() + } + other => panic!("expected summary message, found {other:?}"), + }; + assert_eq!(summary, summary_text); } } diff --git a/codex-rs/core/src/codex_conversation.rs b/codex-rs/core/src/codex_conversation.rs index d3b00046fc..5bb9c97c5b 100644 --- a/codex-rs/core/src/codex_conversation.rs +++ b/codex-rs/core/src/codex_conversation.rs @@ -3,16 +3,21 @@ use crate::error::Result as CodexResult; use crate::protocol::Event; use crate::protocol::Op; use crate::protocol::Submission; +use std::path::PathBuf; pub struct CodexConversation { codex: Codex, + rollout_path: PathBuf, } /// Conduit for the bidirectional stream of messages that compose a conversation /// in Codex. impl CodexConversation { - pub(crate) fn new(codex: Codex) -> Self { - Self { codex } + pub(crate) fn new(codex: Codex, rollout_path: PathBuf) -> Self { + Self { + codex, + rollout_path, + } } pub async fn submit(&self, op: Op) -> CodexResult { @@ -27,4 +32,8 @@ impl CodexConversation { pub async fn next_event(&self) -> CodexResult { self.codex.next_event().await } + + pub fn rollout_path(&self) -> PathBuf { + self.rollout_path.clone() + } } diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs new file mode 100644 index 0000000000..4cb4d4a06a --- /dev/null +++ b/codex-rs/core/src/codex_delegate.rs @@ -0,0 +1,300 @@ +use std::sync::Arc; +use std::sync::atomic::AtomicU64; + +use async_channel::Receiver; +use async_channel::Sender; +use codex_async_utils::OrCancelExt; +use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; +use codex_protocol::protocol::Event; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::ExecApprovalRequestEvent; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::Submission; +use codex_protocol::user_input::UserInput; +use tokio_util::sync::CancellationToken; + +use crate::AuthManager; +use crate::codex::Codex; +use crate::codex::CodexSpawnOk; +use crate::codex::SUBMISSION_CHANNEL_CAPACITY; +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::config::Config; +use crate::error::CodexErr; +use codex_protocol::protocol::InitialHistory; + +/// Start an interactive sub-Codex conversation and return IO channels. +/// +/// The returned `events_rx` yields non-approval events emitted by the sub-agent. +/// Approval requests are handled via `parent_session` and are not surfaced. +/// The returned `ops_tx` allows the caller to submit additional `Op`s to the sub-agent. +pub(crate) async fn run_codex_conversation_interactive( + config: Config, + auth_manager: Arc, + parent_session: Arc, + parent_ctx: Arc, + cancel_token: CancellationToken, + initial_history: Option, +) -> Result { + let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); + let (tx_ops, rx_ops) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); + + let CodexSpawnOk { codex, .. } = Codex::spawn( + config, + auth_manager, + initial_history.unwrap_or(InitialHistory::New), + SessionSource::SubAgent(SubAgentSource::Review), + ) + .await?; + let codex = Arc::new(codex); + + // Use a child token so parent cancel cascades but we can scope it to this task + let cancel_token_events = cancel_token.child_token(); + let cancel_token_ops = cancel_token.child_token(); + + // Forward events from the sub-agent to the consumer, filtering approvals and + // routing them to the parent session for decisions. + let parent_session_clone = Arc::clone(&parent_session); + let parent_ctx_clone = Arc::clone(&parent_ctx); + let codex_for_events = Arc::clone(&codex); + tokio::spawn(async move { + let _ = forward_events( + codex_for_events, + tx_sub, + parent_session_clone, + parent_ctx_clone, + cancel_token_events.clone(), + ) + .or_cancel(&cancel_token_events) + .await; + }); + + // Forward ops from the caller to the sub-agent. + let codex_for_ops = Arc::clone(&codex); + tokio::spawn(async move { + forward_ops(codex_for_ops, rx_ops, cancel_token_ops).await; + }); + + Ok(Codex { + next_id: AtomicU64::new(0), + tx_sub: tx_ops, + rx_event: rx_sub, + }) +} + +/// Convenience wrapper for one-time use with an initial prompt. +/// +/// Internally calls the interactive variant, then immediately submits the provided input. +pub(crate) async fn run_codex_conversation_one_shot( + config: Config, + auth_manager: Arc, + input: Vec, + parent_session: Arc, + parent_ctx: Arc, + cancel_token: CancellationToken, + initial_history: Option, +) -> Result { + // Use a child token so we can stop the delegate after completion without + // requiring the caller to cancel the parent token. + let child_cancel = cancel_token.child_token(); + let io = run_codex_conversation_interactive( + config, + auth_manager, + parent_session, + parent_ctx, + child_cancel.clone(), + initial_history, + ) + .await?; + + // Send the initial input to kick off the one-shot turn. + io.submit(Op::UserInput { items: input }).await?; + + // Bridge events so we can observe completion and shut down automatically. + let (tx_bridge, rx_bridge) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); + let ops_tx = io.tx_sub.clone(); + let io_for_bridge = io; + tokio::spawn(async move { + while let Ok(event) = io_for_bridge.next_event().await { + let should_shutdown = matches!( + event.msg, + EventMsg::TaskComplete(_) | EventMsg::TurnAborted(_) + ); + let _ = tx_bridge.send(event).await; + if should_shutdown { + let _ = ops_tx + .send(Submission { + id: "shutdown".to_string(), + op: Op::Shutdown {}, + }) + .await; + child_cancel.cancel(); + break; + } + } + }); + + // For one-shot usage, return a closed `tx_sub` so callers cannot submit + // additional ops after the initial request. Create a channel and drop the + // receiver to close it immediately. + let (tx_closed, rx_closed) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); + drop(rx_closed); + + Ok(Codex { + next_id: AtomicU64::new(0), + rx_event: rx_bridge, + tx_sub: tx_closed, + }) +} + +async fn forward_events( + codex: Arc, + tx_sub: Sender, + parent_session: Arc, + parent_ctx: Arc, + cancel_token: CancellationToken, +) { + while let Ok(event) = codex.next_event().await { + match event { + // ignore all legacy delta events + Event { + id: _, + msg: EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_), + } => continue, + Event { + id: _, + msg: EventMsg::SessionConfigured(_), + } => continue, + Event { + id, + msg: EventMsg::ExecApprovalRequest(event), + } => { + // Initiate approval via parent session; do not surface to consumer. + handle_exec_approval( + &codex, + id, + &parent_session, + &parent_ctx, + event, + &cancel_token, + ) + .await; + } + Event { + id, + msg: EventMsg::ApplyPatchApprovalRequest(event), + } => { + handle_patch_approval( + &codex, + id, + &parent_session, + &parent_ctx, + event, + &cancel_token, + ) + .await; + } + other => { + let _ = tx_sub.send(other).await; + } + } + } +} + +/// Forward ops from a caller to a sub-agent, respecting cancellation. +async fn forward_ops( + codex: Arc, + rx_ops: Receiver, + cancel_token_ops: CancellationToken, +) { + loop { + let op: Op = match rx_ops.recv().or_cancel(&cancel_token_ops).await { + Ok(Ok(Submission { id: _, op })) => op, + Ok(Err(_)) | Err(_) => break, + }; + let _ = codex.submit(op).await; + } +} + +/// Handle an ExecApprovalRequest by consulting the parent session and replying. +async fn handle_exec_approval( + codex: &Codex, + id: String, + parent_session: &Session, + parent_ctx: &TurnContext, + event: ExecApprovalRequestEvent, + cancel_token: &CancellationToken, +) { + // Race approval with cancellation and timeout to avoid hangs. + let approval_fut = parent_session.request_command_approval( + parent_ctx, + parent_ctx.sub_id.clone(), + event.command, + event.cwd, + event.reason, + event.risk, + ); + let decision = await_approval_with_cancel( + approval_fut, + parent_session, + &parent_ctx.sub_id, + cancel_token, + ) + .await; + + let _ = codex.submit(Op::ExecApproval { id, decision }).await; +} + +/// Handle an ApplyPatchApprovalRequest by consulting the parent session and replying. +async fn handle_patch_approval( + codex: &Codex, + id: String, + parent_session: &Session, + parent_ctx: &TurnContext, + event: ApplyPatchApprovalRequestEvent, + cancel_token: &CancellationToken, +) { + let decision_rx = parent_session + .request_patch_approval( + parent_ctx, + parent_ctx.sub_id.clone(), + event.changes, + event.reason, + event.grant_root, + ) + .await; + let decision = await_approval_with_cancel( + async move { decision_rx.await.unwrap_or_default() }, + parent_session, + &parent_ctx.sub_id, + cancel_token, + ) + .await; + let _ = codex.submit(Op::PatchApproval { id, decision }).await; +} + +/// Await an approval decision, aborting on cancellation. +async fn await_approval_with_cancel( + fut: F, + parent_session: &Session, + sub_id: &str, + cancel_token: &CancellationToken, +) -> codex_protocol::protocol::ReviewDecision +where + F: core::future::Future, +{ + tokio::select! { + biased; + _ = cancel_token.cancelled() => { + parent_session + .notify_approval(sub_id, codex_protocol::protocol::ReviewDecision::Abort) + .await; + codex_protocol::protocol::ReviewDecision::Abort + } + decision = fut => { + decision + } + } +} diff --git a/codex-rs/core/src/command_safety/is_dangerous_command.rs b/codex-rs/core/src/command_safety/is_dangerous_command.rs index 852af93ef9..b422513674 100644 --- a/codex-rs/core/src/command_safety/is_dangerous_command.rs +++ b/codex-rs/core/src/command_safety/is_dangerous_command.rs @@ -1,4 +1,4 @@ -use crate::bash::parse_bash_lc_plain_commands; +use crate::bash::parse_shell_lc_plain_commands; pub fn command_might_be_dangerous(command: &[String]) -> bool { if is_dangerous_to_call_with_exec(command) { @@ -6,7 +6,7 @@ pub fn command_might_be_dangerous(command: &[String]) -> bool { } // Support `bash -lc "